集成 OpenHub 与 WeKnora Page AI
This commit is contained in:
@@ -71,6 +71,9 @@
|
||||
- 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进。`mnote.page.save` 只作为页面级兜底写入工具。
|
||||
- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。
|
||||
- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selection,Hermes 或 Reasonix 在白名单目录内用自身 patch/diff/文件编辑能力写入,MNote 通过 watcher / refresh 同步 tiptap。`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback;`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。
|
||||
- Page AI / OpenHub / 知识库这类用户入口首屏必须优先保证真实任务区可用:对话、输入框、知识库列表/详情占主体空间;debug、scope、health、route guard 等诊断信息只能放在折叠诊断区、开发态或日志中,不得默认挤占用户输入和阅读区域。
|
||||
- MNote + OpenHub 深度融合的交互能力优先改 OpenHub 源码本体(React/FastAPI/服务边界),MNote 只提供 auth、scope、proxy、文件/引用回跳等宿主胶水;不得用 MNote 侧 DOM 注入、悬浮按钮或拦截脚本伪装成 OpenHub 原生能力。
|
||||
- OpenHub/opencode 等 legacy fallback 只能保留为显式 debug/internal 边界;普通用户 UI 不得出现切换到 fallback 的按钮、入口或引导文案。
|
||||
- 涉及知识库资料索引 / OCR / RAG 时,默认沿 `mnote.knowledge_rag.*` / `/api/knowledge-rag/*` 和 LightRAG source registry 推进。图片源不能直接假设会被 LightRAG `/documents/scan` 识别,当前通过 MNote 生成 Markdown wrapper 后进入 LightRAG;LightRAG 后台完成状态通过 MNote 有界 status bridge/backoff 同步到 registry 与 FileTree 灯号,而不是新增无界轮询。
|
||||
- 需要架构判断时,优先参考:
|
||||
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
>
|
||||
> 当前状态:`DONE`
|
||||
>
|
||||
> 2026-06-25 历史口径:本文记录 LightRAG provider 当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;LightRAG 仅保留为 `lightrag_legacy` / fallback / 迁移对照边界。
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / plugin-ui / external-provider
|
||||
>
|
||||
> 取代:`design/old/07-ai/process/7-49-local-understanding-graphrag-kernel-v1.md`
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
>
|
||||
> 状态:`done`
|
||||
>
|
||||
> 2026-06-25 历史口径:本文记录 LightRAG hardening 当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / 03-rust-web / plugin-ui
|
||||
>
|
||||
> 来源:`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P0 审计
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
>
|
||||
> 当前状态:`DONE`
|
||||
>
|
||||
> 2026-06-25 历史口径:本文记录 LightRAG 图片 OCR 与搜索召回链路当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / local-search
|
||||
>
|
||||
> 上位依据:`design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
>
|
||||
> 当前状态:`done`
|
||||
>
|
||||
> 2026-06-25 历史口径:本文记录 LightRAG DOCX 引用与定位合同当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / 03-rust-web / office-preview
|
||||
>
|
||||
> 关联:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
>
|
||||
> 当前状态:`done`
|
||||
>
|
||||
> 2026-06-25 历史口径:本文记录 LightRAG native DOCX sidecar 当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。
|
||||
>
|
||||
> Owner:07-ai / knowledge-rag / LightRAG native parser / office-preview
|
||||
>
|
||||
> 前置完成:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
>
|
||||
> 当前状态:`DONE`
|
||||
>
|
||||
> 2026-06-25 历史口径:本文中 LightRAG 相关能力记录的是当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 run/config/facade 参考与 `lightrag_legacy` 迁移对照边界。
|
||||
>
|
||||
> Owner:07-ai / Page AI runtime / Hermes client runs / Reasonix ACP / Knowledge RAG facade
|
||||
>
|
||||
> 参考代码:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
> 状态补充(2026-06-25):本稿降级为 opencode runtime / iframe fallback 参考;Page AI 产品主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。
|
||||
> 状态补充(2026-06-25):本稿冻结为 opencode runtime / 官方 opencode WebUI iframe fallback 参考;Page AI 产品主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。官方 iframe 仅在 OpenHub AI 面板不可用、调试 opencode 原生行为或做回归对照时启用,不再作为默认产品路径。
|
||||
|
||||
# 7-65 [process] Page AI opencode WebUI embed v1
|
||||
|
||||
> 创建时间:2026-06-23
|
||||
>
|
||||
> 当前状态:`PROCESS / opencode-chat style iframe MVP 已落地,bridge 与 event receipt 已补第一版`
|
||||
> 当前状态:`FROZEN / 官方 opencode iframe fallback,OpenHub + WeKnora + MNote 深度融合为主线`
|
||||
>
|
||||
> Owner:07-ai / Page AI / opencode WebUI embed
|
||||
>
|
||||
@@ -21,14 +21,15 @@
|
||||
|
||||
## 1. 核心结论
|
||||
|
||||
废弃 MNote Page AI 自研 provider 接入与 Board-first 产品壳,改为 **只接入 opencode 官方 runtime + 官方 WebUI**。
|
||||
废弃 MNote Page AI 自研 provider 接入与 Board-first 产品壳后,本稿曾把 **opencode 官方 runtime + 官方 WebUI** 作为主线;截至 `7-68`,该方案降级为 fallback。
|
||||
|
||||
```text
|
||||
MNote Page AI = MNote 宿主壳 + opencode 官方 WebUI + MNote 打开/刷新/上下文集成
|
||||
opencode = 官方 WebUI + 官方 HTTP server/OpenAPI/SSE + 官方 SDK + opencodego provider
|
||||
fallback MNote Page AI = MNote 宿主壳 + opencode 官方 WebUI iframe + MNote 打开/刷新/上下文集成
|
||||
主线 Page AI = MNote 宿主壳 + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + WeKnora
|
||||
opencode = agent runtime / HTTP server / OpenAPI / SSE / SDK / opencodego provider
|
||||
```
|
||||
|
||||
MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker 作为页面 AI provider。它们可以继续存在于历史、调试或外部工作流边界,但不进入 Page AI 主路径。
|
||||
MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker 作为页面 AI provider。它们可以继续存在于历史、调试或外部工作流边界,但不进入 Page AI 主路径。官方 opencode WebUI iframe 也不再进入默认产品主路径,只作为 fallback。
|
||||
|
||||
选择 opencode 的原因:
|
||||
- `opencode web` 官方提供本地 WebUI,不需要 MNote 自研完整聊天前端。
|
||||
@@ -52,7 +53,7 @@ MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker
|
||||
|
||||
### 2.2 仍可保留的边界
|
||||
|
||||
- `mnote.doc.*`、`mnote.block.*`、LightRAG facade 等工具可继续作为 opencode 可调用的 MCP/工具能力。
|
||||
- `mnote.doc.*`、`mnote.block.*`、旧 LightRAG legacy facade 等工具可继续作为 fallback/compat 边界;默认知识库工具应改向 WeKnora。
|
||||
- Agent Board 仍可作为外部 workflow/QA/review 系统,不再作为 Page AI 默认聊天后端。
|
||||
- CodexMobile 可保留为备选 spike 或体验对照,不作为当前实现目标。
|
||||
|
||||
@@ -60,7 +61,7 @@ MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker
|
||||
|
||||
### 3.1 MNote 只做四件事
|
||||
|
||||
1. **上下文**:当前页、选区、页面标题、真实 `.md` 路径、workspaceId、allowed roots、LightRAG 引用。
|
||||
1. **上下文**:当前页、选区、页面标题、真实 `.md` 路径、workspaceId、allowed roots、WeKnora 引用;旧 LightRAG 只作为 legacy/fallback 口径保留。
|
||||
2. **授权**:把 MNote local-first 文件权限转换为 opencode permission / external_directory / working directory。
|
||||
3. **嵌入**:第一版通过 MNote 同源受登录态保护反代嵌入 opencode 官方 WebUI;`npm run dev:hot` 默认拉起 `opencode serve --hostname=127.0.0.1 --port 4096`。
|
||||
4. **回执**:监听 opencode event/diff,触发 MNote watcher 刷新,记录 Page AI session binding。
|
||||
@@ -203,7 +204,7 @@ MNote open Page AI
|
||||
- 当前页真实 Markdown 路径。
|
||||
- selection 文本。
|
||||
- allowed roots 与读写权限。
|
||||
- LightRAG 引用摘要。
|
||||
- WeKnora 引用摘要;旧 LightRAG 引用只作为 legacy/fallback。
|
||||
- 当前任务约束:优先编辑 primaryTarget,禁止越权修改。
|
||||
|
||||
当前状态:`done for MVP`。host chrome 已展示当前页、真实 Markdown path(可定位时)、selection、allowed roots/writable 状态;打开 Page AI 时会调用 `/api/page-ai/opencode/session`,用 `noReply=true` 向 opencode session 注入 MNote context envelope,并把 iframe 打到绑定 session URL。2026-06-24 已按 opencode-chat 参考改回官方 WebUI iframe 主路径,MNote-native timeline 仅保留为 debug/receipt 边界。
|
||||
@@ -483,8 +484,8 @@ $MNOTE_DATA/users/<user-id>/opencode/<workspace-hash>/state/opencode/
|
||||
不建议直接搬入的部分:
|
||||
|
||||
- OpenHub 自带登录、用户管理、admin 页,与 MNote control-plane auth 重叠;应替换成 MNote 登录态。
|
||||
- OpenHub FastAPI 后端与 MNote Rust SSR/control-plane 双后端并存会增加部署复杂度;长期应把必要 API/DB schema 移植到 Rust,而不是新增常驻 Python 服务。
|
||||
- OpenHub 自研知识库/记忆/任务会与 MNote 现有 LightRAG、workspace、tree/file resource、control-plane 产生事实源冲突;应按模块逐步映射。
|
||||
- OpenHub FastAPI 后端与 MNote Rust SSR/control-plane 双后端并存会增加部署复杂度;此判断已被 `7-68` 覆盖,当前第一阶段保留 OpenHub FastAPI/Redis/opencode client。
|
||||
- OpenHub 自研知识库/记忆/任务会与 MNote WeKnora、workspace、tree/file resource、control-plane 产生事实源冲突;旧 LightRAG 仅作为 legacy/fallback 参考。
|
||||
- OpenHub 不是 opencode 内核级强隔离,仍需 MNote 反代和 session ownership 限制。
|
||||
|
||||
推荐融合路线:
|
||||
@@ -552,4 +553,3 @@ OpenHub 知识库真实实现:
|
||||
3. MNote Rust adapter 提供 OpenHub-compatible `/api/knowledge/*`,内部走 WeKnora 或本地 fallback。
|
||||
4. 保留 OpenHub 轻量知识库作为“未配置 WeKnora 时的 local fallback / 用户手工短知识”,但不能称为默认知识库主线。
|
||||
5. 新设计稿应明确:`OpenHub UI` 负责交互,`MNote control-plane` 负责用户与权限,`WeKnora` 负责知识库索引与检索,`opencode` 负责 agent 执行。
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
# 7-66 [process] Page AI opencode-native UI v1
|
||||
|
||||
> 状态补充(2026-06-25):本稿冻结为 native UI fallback / debug receipt 参考;Page AI 产品主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。不得继续扩写 MNote 自研聊天框作为默认 Page AI UI。
|
||||
|
||||
> 创建时间:2026-06-24
|
||||
>
|
||||
> 当前状态:`RECYCLED / 失败方向,仅保留为反例`
|
||||
> 当前状态:`FROZEN / native UI fallback,仅保留为 debug receipt 与失败方向参考`
|
||||
>
|
||||
> Owner:07-ai / Page AI / opencode-native UI
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本稿提出的 MNote-native opencode UI 方向已废弃。用户确认目标应参考 `opencode-chat`:**MNote 薄宿主壳 + 官方 opencode WebUI iframe + 最小 host bridge**,而不是在 MNote 内复刻 opencode 消息流、tool UI、permission UI。
|
||||
本稿提出的 MNote-native opencode UI 方向已冻结为 fallback。当前主线不是在 MNote 内复刻 opencode 消息流、tool UI、permission UI,也不是回到官方 opencode WebUI iframe 默认路径,而是 `7-68` 定义的 **MNote 薄宿主壳 + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + WeKnora**。
|
||||
|
||||
因此 `7-66` 只作为反例保留:native message timeline / composer / permission cards 可以临时作为 debug receipt,但不得成为 Page AI 主 UI,也不继续扩写。当前主线回到 `7-65`:
|
||||
因此 `7-66` 只作为反例和 fallback 保留:native message timeline / composer / permission cards 可以临时作为 debug receipt、故障诊断或 OpenHub 不可用时的降级参考,但不得成为 Page AI 主 UI,也不继续扩写自研聊天框。
|
||||
|
||||
```text
|
||||
MNote Page AI = MNote host chrome + opencode 官方 WebUI iframe + MNote open/refresh/changed files receipt
|
||||
opencode = 官方聊天 UI、session、tool、permission、diff、model/provider
|
||||
主线 Page AI = MNote host chrome + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + WeKnora
|
||||
native fallback = MNote debug receipt + opencode session/event/diff 摘要
|
||||
iframe fallback = 7-65 官方 opencode WebUI iframe
|
||||
```
|
||||
|
||||
## 2. 参考结论
|
||||
@@ -35,7 +38,7 @@ opencode = 官方聊天 UI、session、tool、permission、diff、model/pro
|
||||
- sidebar chat + diff viewer + multi-session tabs;自动启动 `opencode serve`,通过 HTTP/SSE 通信。
|
||||
- React webview 渲染 session、message parts、tool cards、permission、diff、provider/model。
|
||||
|
||||
对 MNote 的启发:不要再塞 iframe;应该使用 opencode API/SSE 重渲染 MNote-native UI,并保留 MNote 打开文件/刷新编辑器。
|
||||
对 MNote 的历史启发:直接重渲染 opencode API/SSE 会把 MNote 拉回自研聊天框路线。`7-68` 已覆盖该判断:优先嵌入 OpenHub AI 面板并保留 OpenHub 后端栈,native UI 只做 fallback / debug receipt。
|
||||
|
||||
## 3. 必须覆盖的 opencode 功能
|
||||
|
||||
@@ -75,7 +78,7 @@ opencode = 官方聊天 UI、session、tool、permission、diff、model/pro
|
||||
- changed file click:`window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。
|
||||
- 当前页被修改:`refreshPrimaryDocument({ reason: 'page-ai-opencode-event' })`。
|
||||
- selection/current page/allowed roots 由现有 Page AI target runtime 计算。
|
||||
- iframe 反代只保留 `debug fallback`,默认 UI 不使用 iframe。
|
||||
- native UI 只保留 `debug fallback`;默认 UI 使用 `7-68` 的 OpenHub AI 面板嵌入路线。
|
||||
|
||||
## 5. 第一版验收
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
> 状态补充(2026-06-25):本稿为 OpenHub 初步融合稿;更完整的 OpenHub + WeKnora + MNote 取舍与实施主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。
|
||||
> 状态补充(2026-06-25):本稿为 OpenHub 初步融合稿,已被 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 覆盖。路线从“抽取/重写 OpenHub 组件 + MNote Rust adapter”改为“嵌入 OpenHub AI 面板 + 保留 OpenHub FastAPI/Redis/opencode client + WeKnora 工具/知识底座”。
|
||||
|
||||
# 7-67 OpenHub Page AI 深度融合设计 v1
|
||||
|
||||
@@ -10,23 +10,23 @@ Owner:07-ai / mnote-web / control-plane
|
||||
|
||||
`7-65` 的 opencode 官方 WebUI iframe 路线验证了 opencode runtime、同源反代、session binding、文件打开/刷新等接缝,但 UI 深度融合受 iframe 与官方 WebUI 结构限制。用户目标已经调整为:尽量复用成熟社区项目 OpenHub 的前端、消息库、权限、知识库 UI 和 opencode 接入模式,把 MNote Page AI 做成类似 VSCode + Cline 的一体化侧边栏,而不是 MNote 自研一个简陋聊天框。
|
||||
|
||||
当前 Page AI 新主路径:
|
||||
当前 Page AI 主路径以 `7-68` 为准:
|
||||
|
||||
```text
|
||||
MNote Rust SSR / control-plane / local workspace
|
||||
-> OpenHub Chat 子系统 UI(裁剪融合)
|
||||
-> MNote Rust adapter(兼容 OpenHub API 形状)
|
||||
-> opencode serve runtime
|
||||
-> WeKnora knowledge provider(长期知识库底座)
|
||||
-> OpenHub AI 面板(只暴露 AI 能力,裁剪非 AI 入口)
|
||||
-> OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission;MCP 由 MNote/WeKnora/opencode 工具配置承接
|
||||
-> OpenHub opencode client / opencode serve runtime
|
||||
-> WeKnora knowledge provider(唯一知识库底座)
|
||||
```
|
||||
|
||||
`7-65` 降级为 opencode runtime spike / fallback;不再把官方 opencode WebUI iframe 作为默认产品 UI。
|
||||
`7-65` 降级为 opencode runtime / 官方 iframe fallback;`7-66` 降级为 native UI fallback;本稿的 Rust adapter / 自建 message store 路线也冻结为参考,不再作为执行主线。
|
||||
|
||||
## 2. 硬边界
|
||||
|
||||
- 不恢复 Reasonix / ZCode / Hermes / Board / CodexMobile 为 Page AI 默认后端。
|
||||
- 不新增 Page AI Leptos island;Page AI 仍属于 `mnote-web` host runtime 与普通前端资源。
|
||||
- 不直接引入 OpenHub 登录、用户管理、admin 整站后端;用户、权限、workspace 真相归 MNote control-plane。
|
||||
- 不直接引入 OpenHub 登录、admin、FileManager、KnowledgeManager 等非 AI 入口;用户、权限、workspace 真相归 MNote control-plane。
|
||||
- 不把 OpenHub 自带 SQLite 文本知识库当作 MNote 长期知识库底座;长期知识库 provider 是 WeKnora。
|
||||
- 不使用 `--dangerously-skip-permissions` 作为默认路径。
|
||||
- 不把 opencode 原生 session 列表裸露给前端;所有 session 必须绑定 MNote 用户、rootUri、page path。
|
||||
@@ -53,7 +53,7 @@ MNote Rust SSR / control-plane / local workspace
|
||||
- SmartEntity / Team / Scheduler。
|
||||
- GitTimeMachine 的 restore 写入能力;可先只显示 diff / changed files。
|
||||
|
||||
### 3.2 后端模式
|
||||
### 3.2 后端模式(已由 7-68 覆盖)
|
||||
|
||||
可复用其 API contract 和 opencode 调用模式:
|
||||
|
||||
@@ -64,28 +64,28 @@ MNote Rust SSR / control-plane / local workspace
|
||||
- `/api/files/*`
|
||||
- opencode `/session?directory=<workspace>`、`/session/{id}/prompt_async?directory=<workspace>`、`/global/event?directory=<workspace>`
|
||||
|
||||
但实现落在 Rust `mnote-web` / control-plane,不新增常驻 FastAPI 后端。
|
||||
本稿原判断是“实现落在 Rust `mnote-web` / control-plane,不新增常驻 FastAPI 后端”。`7-68` 已更正:第一阶段保留 OpenHub FastAPI + Redis + opencode client 作为 AI 运行栈,MNote 只做登录态、scope 注入、入口裁剪、文件打开和 citation 回跳桥。
|
||||
|
||||
## 4. MNote 目标架构
|
||||
|
||||
### 4.1 UI 层
|
||||
|
||||
`sidebar-page-ai-runtime.js` 不再维护自研聊天消息渲染主链,而是挂载 OpenHub-derived Page AI micro frontend:
|
||||
`sidebar-page-ai-runtime.js` 不再维护自研聊天消息渲染主链,而是承载 `7-68` 的 OpenHub AI 面板嵌入:
|
||||
|
||||
- MNote-native host chrome:当前页、selection、workspace、授权状态、runtime 状态、changed file chips。
|
||||
- OpenHub-derived chat area:消息、tool call、diff、history、input、附件。
|
||||
- OpenHub AI 面板:消息、tool call、diff、history、input、skill/permission、WeKnora CLI/MCP tool 状态、运行日志。
|
||||
- Bridge:只处理 MNote 专属动作:`open-file`、`refresh-file`、`insert-context`、`session-ready`、`changed-files`。
|
||||
|
||||
### 4.2 Rust adapter 层
|
||||
### 4.2 Rust adapter 层(冻结为旧设想)
|
||||
|
||||
新增或重构 Page AI API:
|
||||
以下自建 session/message 表路线已被 `7-68` 覆盖,不作为当前执行主线:
|
||||
|
||||
- `page_ai_sessions`:MNote 用户维度的跨浏览器 session binding。
|
||||
- `page_ai_messages`:用户消息、assistant 消息、tool call、opencode ids、状态。
|
||||
- `page_ai_context_snapshots`:当前页标题、真实 Markdown 路径、selection、rootUri、allowed roots、知识摘要。
|
||||
- `page_ai_changed_files`:opencode event/diff 得到的 changed files 与 MNote resource mapping。
|
||||
|
||||
所有 API 必须读取 MNote 登录态,禁止由前端自由传入 user id 或任意 directory。
|
||||
当前主线是:OpenHub session/message 是 Page AI 会话真相;MNote control-plane 只保存 binding、scope、artifact/open-reference 索引,禁止复制消息全文主存储。
|
||||
|
||||
### 4.3 opencode runtime 层
|
||||
|
||||
@@ -167,4 +167,4 @@ MVP 完成条件:
|
||||
|
||||
## 7. 当前结论
|
||||
|
||||
OpenHub 是目前最适合 MNote Page AI 深度融合的参考实现。它不解决 opencode 内核级多用户隔离,也不提供完整知识库底座,但它提供了 MNote 当前最缺的成熟 Chat/UI/message/session/diff/file/knowledge 管理壳。正确路线不是整站照搬 OpenHub,而是把 OpenHub Page AI 子系统移植为 MNote 原生 Page AI UI,后端由 MNote Rust adapter 接 opencode 与 WeKnora。
|
||||
OpenHub 仍是目前最适合 MNote Page AI 深度融合的参考实现,但本文已被 `7-68` 覆盖。正确路线不是把 OpenHub Page AI 子系统重写成 MNote 原生 UI,也不是由 MNote Rust adapter 重写 opencode client,而是在 MNote 侧嵌入 OpenHub AI 面板,保留 OpenHub FastAPI/Redis/opencode client,由 MNote 提供登录态、workspace scope、文件打开、citation 回跳和 WeKnora 工具/知识底座边界。
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# 7-68 OpenHub + WeKnora + MNote 深度融合执行 Checklist v1
|
||||
|
||||
状态:process
|
||||
Owner:07-ai / mnote-web / control-plane / knowledge-provider
|
||||
日期:2026-06-25
|
||||
|
||||
上位设计:`design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md`
|
||||
|
||||
## 0. 执行原则
|
||||
|
||||
- 保留各系统已完成能力:OpenHub 保留 AI 面板、FastAPI、SQLite session/message、Redis 临时态、skill/tool permission、opencode client;WeKnora 保留 KB/API/CLI/MCP/search/index;MNote 保留 auth/workspace/resource tree/document pane/source registry。
|
||||
- 只在产品真相冲突处做胶水:登录入口、workspace/rootUri scope、知识库 source truth、文件打开、citation 回跳、OpenHub 非 AI 入口裁剪、Git snapshot 禁用。
|
||||
- 不把未确认能力写成已完成能力:当前 OpenHub 源码未确认独立 MCP 管理 UI/API,第一阶段通过 MNote 注册 WeKnora CLI/MCP/API tool 到 OpenHub/opencode。
|
||||
- 不把 Redis 当持久真相:OpenHub message 真相在 SQLite,MNote auth/source/file truth 在 control-plane/本地文件,WeKnora Redis 属于其内部队列/缓存。
|
||||
|
||||
## 1. P0 设计冻结与旧路径降级
|
||||
|
||||
- [x] 在 `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md` 顶部加覆盖说明:官方 opencode WebUI iframe 仅保留 fallback,不是 Page AI 主线。
|
||||
- [x] 在 `design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md` 顶部加覆盖说明:自研 native UI 仅保留 fallback,不继续扩自研聊天框。
|
||||
- [x] 在 `design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md` 顶部加覆盖说明:已被 7-68 覆盖,路线改为嵌入 OpenHub AI 面板并保留 FastAPI/opencode client。
|
||||
- [x] 搜索 `design/07-ai/`、`scripts/`、`rust/crates/mnote-web/browser/` 中仍把 LightRAG 描述为默认知识库 provider 的文案,列出需改文件,不在本任务中顺手改代码。
|
||||
- [x] 标记 `7-50` 到 `7-57` 中 LightRAG 相关 done 文档为历史事实:曾经完成,不再代表当前新主线。
|
||||
|
||||
P0 搜索记录(2026-06-25):
|
||||
|
||||
- 已在 `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`、`7-51`、`7-52`、`7-55`、`7-56`、`7-57` 顶部标记历史口径:LightRAG 曾完成,但不再代表当前新主线;WeKnora 是 7-68 默认 provider。
|
||||
- 仍需后续单独迁移或归档的 active/process 文档:`design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`、`design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md`、`design/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md`。这些文档仍含 LightRAG 默认主线语义,应按 7-68 后续批次改为 WeKnora / provider-neutral 或移入 `design/old/`。
|
||||
- 仍需后续代码迁移的 LightRAG 残留:`rust/crates/mnote-web/browser/document-resource-tab-runtime.js`、`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`、`scripts/reasonix-acp-wrapper.mjs`、`scripts/task538-knowledge-rag-source-scope-api-smoke.js`、`scripts/task542-knowledge-rag-search-grouping-and-short-query-smoke.js`、`scripts/TESTING_REFERENCE.md`、`scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`。本轮先完成 7-68 主链静态边界与 WeKnora facade,不批量重写所有 legacy smoke。
|
||||
|
||||
## 2. P0 OpenHub 源码事实冻结
|
||||
|
||||
- [x] 固定 OpenHub 源码路径:`/tmp/mnote-openhub-research/OpenHub`;若后续要深改,先建立可复核源码快照或 CodeGraph 索引。
|
||||
- [x] 记录 FastAPI 入口:`smart-query-backend/app/main.py`,router 包括 `auth/query/session/admin/files/internal/knowledge/admin_knowledge/channels`。
|
||||
- [x] 记录 opencode client:`app/services/opencode_client.py` 使用 `OPENCODE_BASE_URL`,并支持 `directory` 参数。
|
||||
- [x] 记录 opencode launcher:`app/services/opencode_launcher.py` 可启动 `opencode serve`;集成时 `workdir` 必须来自 MNote rootUri,不用 OpenHub 默认路径。
|
||||
- [x] 记录 session/message 真相:`init_db.py` 的 `conversation_sessions/conversation_messages` 与 `app/database.py`。
|
||||
- [x] 记录 Redis 用途:`app/core/auth.py`、`app/database.py` 中的 token/rate-limit/workspace cache/图片临时态;不得写成消息队列主链。
|
||||
- [x] 记录自动 Git snapshot 触发点:`app/services/stream.py`、`app/services/task_executor.py`、`app/api/session.py` restore API。
|
||||
- [x] 记录冲突入口:`App.jsx` 的 `/login`、`/admin`;`FileManager.jsx`;`KnowledgeManager.jsx`;`GitTimeMachine.jsx`。
|
||||
|
||||
P0 OpenHub 记录(2026-06-25):
|
||||
|
||||
- 源码路径已固定为 `/tmp/mnote-openhub-research/OpenHub`。
|
||||
- FastAPI 入口 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/main.py` include routers:`auth/query/session/admin/files/internal/smart_entity/smart_entity_tasks/knowledge/admin_knowledge/channels`。
|
||||
- `app/config.py` 中 `OPENCODE_BASE_URL` 默认 `http://127.0.0.1:4096`;`app/services/opencode_client.py` 的 `get/post/put/patch` 支持把 `directory` 写入 query params。
|
||||
- `app/services/opencode_launcher.py` 可启动 `opencode serve`;`main.py` 中 `opencode_auto_start` 当前默认 workdir 仍有 OpenHub 自带 fallback,MNote 集成时必须由 rootUri 派生或禁用 auto-start。
|
||||
- Git snapshot 写链真实存在:`app/services/stream.py`、`app/services/task_executor.py` 会 init git / has_changes / create_snapshot;`app/api/session.py` 暴露 snapshot list/detail/diff/restore/restore-file 路由。
|
||||
- 冲突入口真实存在:前端 `src/App.jsx` 有 `/login` 与 `/admin`;OpenHub 还包含 FileManager / KnowledgeManager / GitTimeMachine 相关入口,MNote host 第一阶段必须 guard 或转接。
|
||||
|
||||
## 3. P0 WeKnora 源码事实冻结
|
||||
|
||||
- [x] 固定 WeKnora 源码路径:`/mnt/Data1T/Mnote_data/weknora/WeKnora`。
|
||||
- [x] 记录 KB UI 入口:`frontend/src/views/knowledge/KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`frontend/src/components/knowledge-processing-timeline.vue`。
|
||||
- [x] 记录多 KB 检索:`docs/api/knowledge-search.md` 与 router `/api/v1/knowledge-search`,请求必须带 `knowledge_base_id(s)` 或 `knowledge_ids`。
|
||||
- [x] 记录单 KB hybrid-search:`client/knowledgebase.go` 的 `/api/v1/knowledge-bases/:id/hybrid-search`。
|
||||
- [x] 记录 `SearchResult` 全字段:`id/content/knowledge_id/knowledge_base_id/chunk_index/knowledge_title/start_at/end_at/seq/score/match_type/sub_chunk_id/metadata/chunk_type/parent_chunk_id/image_info/knowledge_filename/knowledge_source/knowledge_channel/chunk_metadata/matched_content/knowledge_description`。
|
||||
- [x] 记录 CLI MCP:`cli/internal/mcp/tools.go`、`cli/cmd/mcp/serve.go`;第一阶段默认采用只读 curated surface。
|
||||
- [x] 记录 Python `mcp-server/` 作为后续写能力参考,第一阶段不默认暴露给 OpenHub/opencode。
|
||||
- [x] 记录 WeKnora Redis/Asynq/Langfuse 属于 WeKnora stack,不和 OpenHub Redis keyspace 混用。
|
||||
|
||||
P0 WeKnora 记录(2026-06-25):
|
||||
|
||||
- 源码路径已固定为 `/mnt/Data1T/Mnote_data/weknora/WeKnora`。
|
||||
- KB UI 入口已定位:`KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue`。
|
||||
- 多 KB 搜索合同来自 `docs/api/knowledge-search.md`:`POST /api/v1/knowledge-search`,请求至少包含 `knowledge_base_id` 或 `knowledge_base_ids`;可用 `knowledge_ids` 限定文件。
|
||||
- `client/knowledgebase.go` 记录 `HybridSearch` 路径 `/api/v1/knowledge-bases/:id/hybrid-search`;`SearchResult` 包含 `id/content/knowledge_id/chunk_index/knowledge_title/start_at/end_at/seq/score/match_type/chunk_type/image_info/metadata/knowledge_filename/knowledge_source/knowledge_channel/matched_content`,服务端还有 parent/sub/chunk metadata 等扩展字段,本轮 MNote mapper 以原始 chunk 保留这些字段。
|
||||
- CLI MCP 已定位:`cli/internal/mcp/tools.go` 注册 curated tools;`cli/cmd/mcp/serve.go` 构建 `weknora mcp serve`,当前 stdio-only。第一阶段 MNote 暴露自己的只读 facade,不默认开放 Python `mcp-server` 写能力。
|
||||
|
||||
## 4. P1 OpenHub 服务栈接入
|
||||
|
||||
- [x] 为 MNote dev-hot 增加 OpenHub 开关:`ENABLE_OPENHUB=1`、`OPENHUB_BACKEND_CMD`、`OPENHUB_BACKEND_PORT`、`OPENHUB_REDIS_URL/DB`、`OPENHUB_OPENCODE_BASE_URL`。
|
||||
- [~] 启动顺序明确为 Redis -> opencode serve -> OpenHub FastAPI -> MNote host health proxy。
|
||||
- [x] health 输出区分 `openhub_fastapi`、`openhub_redis`、`opencode_serve`、`weknora_api`、`weknora_cli_mcp`、`mnote_binding`。
|
||||
- [x] 禁止 kill-port/destructive launcher 行为;端口被占用时只报告占用 PID 和进程名。
|
||||
- [~] OpenHub `opencode_auto_start` 若启用,`opencode_workdir` 必须由 MNote 当前 rootUri 派生;否则 MNote 自己启动 opencode serve 并让 OpenHub 复用。
|
||||
- [x] 加环境开关或最小 fork patch 禁用 OpenHub 自动 `git init/commit/restore`。
|
||||
- [~] smoke:OpenHub FastAPI 可 `/openapi.json` 或 health 探针;Redis 可认证连接;opencode serve 可访问;WeKnora CLI profile/doctor 可通过;WeKnora stdio MCP handshake 仍未作为最终通过项。
|
||||
|
||||
P1 OpenHub 服务栈记录:
|
||||
|
||||
- `scripts/desktop-hot.js` 已增加 `ENABLE_OPENHUB`、`OPENHUB_CMD`、`OPENHUB_PORT`、`OPENHUB_BACKEND_DIR`、`OPENHUB_REDIS_HEALTH_URL`、`MNOTE_OPENHUB_BASE_URL`、`MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE`,并补 `scripts/desktop-hot.test.js`。
|
||||
- 当前实现的变量名是 `OPENHUB_CMD` / `OPENHUB_PORT`,不是 checklist 早期草案里的 `OPENHUB_BACKEND_CMD` / `OPENHUB_BACKEND_PORT`;后续若需要兼容别名可再补。
|
||||
- `desktop-hot` 不释放或 kill OpenHub 端口;OpenHub Redis 目前只有 health URL 检查,不负责启动 Redis。
|
||||
- `MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=1` 已作为 MNote 启动环境默认值和 smoke 断言;OpenHub 研究源码 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend` 已最小 patch:`git_snapshot.py` 中央拦截 `init/config/add/commit/checkout/restore/reset/revert`,`stream.py`、`task_executor.py` 跳过自动 snapshot,`session.py` 的 restore / restore-file 在禁用时返回 403。`scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js` 和 Python guard 探针已通过。
|
||||
- 当前真实探针:OpenHub FastAPI `127.0.0.1:18080`、opencode `127.0.0.1:4096`、MNote `3021` 均可达;WeKnora 在 MNote 进程携带 `MNOTE_WEKNORA_API_KEY` 后 `/api/page-ai/openhub/status` 返回 ready。Redis 目前只作为 OpenHub 内部可选依赖/降级链路,MNote health 仅支持外部 URL 探针,不负责启动 Redis;已用 WeKnora `.env` 的 `REDIS_PASSWORD` 真实 `AUTH + PING` 返回 `+OK/+PONG`。
|
||||
- 2026-06-26 WeKnora CLI 真实 profile smoke:临时 `XDG_CONFIG_HOME` 下执行 `weknora profile add mnote-local --host http://127.0.0.1:8080 --use`,再把 MNote 进程中的 `MNOTE_WEKNORA_API_KEY` pipe 给 `weknora auth login --with-token`,`weknora doctor --no-cache --format json` 返回 4/4 checks passed(base URL、auth、server version、credential storage)。`weknora mcp serve --help` 通过;stdio MCP handshake 探针未稳定完成,保留为 dev-hot/orchestration 后续项。
|
||||
|
||||
## 5. P1 登录与 OpenHub Scope
|
||||
|
||||
- [x] 新增/扩展 MNote OpenHub bootstrap endpoint:输入当前 `mnote_session + workspace/rootUri + page_resource_id`,输出 OpenHub runtime scope。
|
||||
- [x] 定义 `openhub_user_key = stable_hash(mnote_user_id)`。
|
||||
- [x] 定义 `openhub_workspace_key = stable_hash(workspace_id, root_uri)`。
|
||||
- [x] 定义 `openhub_session_scope = hash(mnote_user_id, workspace_id, root_uri, page_resource_id)`。
|
||||
- [x] 定义 `weknora_tool_scope = allowed_kb_ids + allowed_source_ids + citation_policy`。
|
||||
- [x] 禁用 OpenHub 前端 `/login` 跳转和 localStorage token 作为 MNote 产品入口;由 MNote proxy/header/bootstrap 注入受控身份。
|
||||
- [x] 保留 OpenHub 后端 user/session owner 校验;映射到 MNote 派生 OpenHub user,不共享单一 OpenHub 用户。
|
||||
- [~] rootUri/workspace 切换时新建或切换 OpenHub session;旧 binding 标记 `stale/archived`。
|
||||
- [x] 测试:两个 MNote 用户同一页面不能读到同一个 OpenHub session/message/skill/tool permission scope。
|
||||
|
||||
P1 scope 记录:
|
||||
|
||||
- 新增 `rust/crates/mnote-web/src/routes/page_ai_openhub.rs`,`/api/page-ai/openhub/bootstrap` 输出 camelCase 与 snake_case scope:`openhubUserKey/openhub_user_key`、`openhubWorkspaceKey/workspace_key`、`openhubSessionScope/session_scope`、`skillScope/skill_scope`、`toolPermissionScope/tool_permission_scope`、`weknoraToolScope/weknora_tool_scope`。
|
||||
- MNote 侧已拒绝 OpenHub JWT/localStorage 作为产品入口;`/page-ai/openhub/login` 等非 AI 路由由 MNote guard。
|
||||
- OpenHub 研究源码已消费 `X-MNote-*` headers 派生后端 user/scope,并保留 session owner guard。
|
||||
- 2026-06-26 `node --check scripts/task775-openhub-mnote-scope-isolation-smoke.js` 与 `node scripts/task775-openhub-mnote-scope-isolation-smoke.js`:通过。直连 OpenHub MNote header scope 创建 session `task775-openhub-scope-1782410072554-157c8c`,Owner A 可读取自己写入的 user message,Owner B 读取同一 session messages 返回 403 `无权访问该会话`;stream 阶段模型不可用不影响 owner guard 验收,因为消息持久化和隔离均已验证。
|
||||
|
||||
## 6. P1 Page AI 嵌入 OpenHub AI 面板
|
||||
|
||||
- [x] 在 `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` 设计嵌入方式:iframe 或 reverse proxy 二选一。
|
||||
- [x] 只暴露 OpenHub AI 页面;`/login`、`/admin`、FileManager、KnowledgeManager、GitTimeMachine、Team/Scheduler/SmartEntity 非 MVP 入口隐藏、404 或转 MNote。
|
||||
- [~] 保留 AI 主界面、history/session 抽屉、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志。
|
||||
- [x] 如果 OpenHub 无 MCP 管理 UI,只显示 WeKnora MCP/CLI tool 连接状态和 MNote scope,不新建完整 MCP 管理器。
|
||||
- [x] MNote context 注入字段:user display、workspace/rootUri、page path/title/resource id、selection 摘要、allowed roots、session/skill/tool/weknora scope。
|
||||
- [x] changed file chip 点击走 MNote document pane bridge。
|
||||
- [x] OpenHub KnowledgeManager 入口若出现,跳转 MNote WeKnora 知识库页;不得打开 OpenHub knowledge tables。
|
||||
- [x] browser smoke:登录 MNote 后打开 Page AI,不出现 OpenHub/WeKnora 登录页。
|
||||
- [x] browser/API smoke:OpenHub session/message 已持久化并可读;刷新后 UI history 恢复已完成真实浏览器验收。
|
||||
|
||||
P1 Page AI host 记录:
|
||||
|
||||
- `sidebar-page-ai-runtime.js` 默认打开 OpenHub host drawer,iframe 指向 `/page-ai/openhub/ai?scope=...`;`localStorage mnote.page_ai.openhub_host=0` 可回退 `/page-ai/opencode`。
|
||||
- `page_ai_openhub.rs` 与 `routes/mod.rs` 已挂 `/page-ai/openhub/ai` 静态 shell 和 `/page-ai/openhub/ai/{*path}` proxy;`login/admin/file/files/knowledge/git` 路由已 guard。
|
||||
- OpenHub React 前端已做 MNote embed 最小补丁:`mnoteEmbed.js` 识别 `/page-ai/openhub/ai` 嵌入态,`BrowserRouter` 使用 `/page-ai/openhub/ai` basename,API 默认走 `/page-ai/openhub/ai/api`,请求自动携带 `scope/mnoteScope`,无 token 不再发送 `Bearer null`,401 不跳 OpenHub `/login`,`/login/admin/file/files/knowledge/git` 前缀导航回 AI 根页;嵌入态隐藏 OpenHub FileManager、KnowledgeManager、GitTimeMachine、SmartEntity/Team 入口和对应 drawer。
|
||||
- MNote `ai_shell` 已从 OpenHub FastAPI 根 `/` 拉取 SPA index.html 并重写 `/assets/...` 到 `/page-ai/openhub/ai/assets/...`;OpenHub React dist 已复制到 `smart-query-backend/static/`。
|
||||
- 2026-06-26 浏览器 smoke `node scripts/task773-page-ai-openhub-browser-smoke.js`:通过。MNote 3000 可达,测试账号真实登录成功(`authMode=sqliteSession`),Page AI drawer、OpenHub host chrome 与 `/page-ai/openhub/ai?scope=...&mnoteScope=...` iframe 可见,未出现 OpenHub/WeKnora 登录页;iframe 内 OpenHub React UI 可见,文本 marker 包含 `OpenHub 平台/开始对话/历史记录/技能管理`,`openhubReactConnected=true`、`staticBoundaryOnly=false`,冲突入口 `文件管理/知识库/时光机/智能体/协作任务/团队状态` 不再出现在嵌入态正文。截图:`tmp/7-68-runtime/page-ai-openhub-browser.png`;结果 JSON:`tmp/7-68-runtime/page-ai-openhub-browser-result.json`。
|
||||
- 2026-06-26 send/history smoke `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。MNote SQLite 测试账号登录后,经 MNote proxy 调 OpenHub `/api/models` 返回 66 个真实 opencode 模型,选用 `opencodego/deepseek-v4-flash` 发送真实 stream,OpenHub SQLite 可读取同一 session 的 user/assistant message。结果 JSON:`tmp/7-68-runtime/openhub-send-smoke-result.json`。
|
||||
- 2026-06-26 changed files bridge smoke `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过。OpenHub MNote host mode `/api/sessions/{session_id}/diff` 从 opencode tool event metadata 提取真实 changed file path,并返回 `diffAvailable=false` 与限制说明,不伪造 diff content;结果命中 `knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md`。OpenHub 前端 `DiffViewer` / `SmartQueryPage` 通过 `postMNoteOpenFile` 发送 `mnote:open-file`,MNote host 监听后复用现有 document pane open-file bridge。
|
||||
- 2026-06-26 `BASE_URL=http://127.0.0.1:3021 node scripts/task786-openhub-history-refresh-browser-smoke.js`:通过。真实浏览器创建 OpenHub session message,刷新后 history drawer 可见 marker,点击 session 后当前 message area 可见同一 marker;OpenHub API message/history 同步验证通过。该轮 stream 返回“所有模型均不可用” error event,但 user message/session/history 持久化与 UI 恢复已验收;真实 AI reply 由 `task774` 覆盖。截图:`tmp/task786-openhub-history-refresh-browser-smoke/openhub-history-refresh.png`。
|
||||
|
||||
## 7. P1 OpenHub 原生 opencode 链路
|
||||
|
||||
- [x] 保留 OpenHub FastAPI prompt/event/diff client,不在 MNote 重写 event parser。
|
||||
- [x] 确认 OpenHub prompt 请求携带 `directory = MNote rootUri`。
|
||||
- [x] 确认 OpenHub 能监听 opencode `/global/event` 并渲染 assistant/tool part。
|
||||
- [x] 确认 OpenHub 能读取 changed files/diff,并将 path 暴露给 MNote bridge。
|
||||
- [x] MNote 只存 artifact index:`openhub_session_id + kind + provider_id + path/citation payload`,不复制消息全文。
|
||||
- [x] 当前打开文档被 opencode/OpenHub changed-file bridge 标记后,MNote 走 watcher 或 `refreshPrimaryDocument()` 刷新。
|
||||
- [~] smoke:让 OpenHub AI 修改 rootUri 内测试 Markdown,MNote document pane 可见变更,且没有 OpenHub Git snapshot commit。当前已覆盖真实 opencode 文件修改 + 模拟 OpenHub changed-file bridge 刷新;单条端到端 AI 修改“当前已打开文档”仍可后续补强。
|
||||
|
||||
P1 opencode 链路记录:
|
||||
|
||||
- MNote 当前只做 host/status/bootstrap/proxy 边界,没有重写 OpenHub prompt/event parser。
|
||||
- OpenHub 后端 `query.py` 已在 MNote host mode 下使用 `resolve_user_workspace(current_user)`,创建 session 与 stream prompt 均把 `directory` 指向 MNote rootUri 解析出的 workspace path。
|
||||
- OpenHub 后端 `query.py` 已补 MNote host mode `/api/models`:不再返回空模型,而是调用真实 opencode `/config/providers`,展开 provider/model 并按 opencode `default` 输出 `default/defaultBuild/defaultPlan`;opencode 不可达时返回明确 empty + error,不伪造 mock model。
|
||||
- 2026-06-26 主控复核:`curl -H X-MNote-* http://127.0.0.1:18080/api/models` 返回 `success=true`、`modelCount=66`、`source=opencode_config_providers`、默认 `openai/gpt-5.3-chat-latest`;`python -m py_compile` 覆盖 `query.py/auth.py/mnote_scope.py` 通过。
|
||||
- 2026-06-26 `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。经 MNote proxy 发送真实 OpenHub stream,事件包含 `session/step-start/text/reasoning/step-finish/message_complete/session_idle`,OpenHub SQLite `/api/sessions/{id}/messages` 可读 user/assistant 两条消息。
|
||||
- 2026-06-26 `MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。opencode 在 MNote rootUri 内把 `knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md` 修改为 `status: MNOTE_OPENHUB_FILE_EDIT_OK`,stream 事件包含 `tool`,workspace `.git` 在前后均不存在,未创建 OpenHub Git snapshot。当前 document pane 刷新由后续 `task779` 覆盖。
|
||||
- 2026-06-26 `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过。MNote proxy fresh bootstrap 后访问 OpenHub `/api/sessions/ses_1002052f0ffeOpebUFbGq4EC76/diff` 返回 `source=opencode_tool_events`、`diffAvailable=false`,changed path 命中上面的真实 file-edit fixture;OpenHub Git snapshot 写链仍禁用,因此只暴露真实 changed path,不生成或伪造行级 diff。当前 document pane 刷新由后续 `task779` 覆盖。
|
||||
- 2026-06-26 `node scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js`:通过。浏览器打开 local-first 测试 Markdown 后,脚本在 rootUri 内模拟 OpenHub/opencode 改写磁盘文件,再触发 MNote OpenHub changed-file bridge;MNote 侧复用 `mnote:open-file` / local-folder event bus synthetic watch batch / `refreshPrimaryDocument()` / `openPrimaryDocument()`,primary document pane 原地显示新内容,并写入 `data-mnote-page-ai-openhub-document-pane-refresh` 诊断 marker。该 smoke 不新增应用轮询。
|
||||
- 2026-06-26 MNote 侧新增 `/api/page-ai/openhub/artifact-index` GET/POST 最小边界:写入 `schema=mnote.page_ai_openhub_artifact_index_record.v1` 的轻量索引,只接受 `openhub_session_id/openhubSessionId + kind + provider_id/providerId + path + citation_payload/citationPayload` 等 locator 字段;默认持久化到 rootUri 下 `.mnote/page-ai-openhub-artifact-index.json`,也可由 `MNOTE_OPENHUB_ARTIFACT_INDEX_PATH` 指定。该实现明确 `messageFulltextCopied=false`,并拒绝 `message/messages/content/text/transcript/conversationMessages/assistantMessage/userMessage` 等 OpenHub user/assistant message 全文字段;不是复制 OpenHub SQLite message 表。
|
||||
- 2026-06-26 `node --check scripts/task778-openhub-artifact-index-static-smoke.js` 与 `node scripts/task778-openhub-artifact-index-static-smoke.js`:通过。静态 smoke 断言 artifact index route、轻量字段、全文字段拒绝、最小文件持久化和本 checklist 记录。
|
||||
- 2026-06-26 主控 runtime 复核:登录 MNote 测试账号后 POST `/api/page-ai/openhub/artifact-index` 写入 `kind=changed_file`、`providerId=opencode-tool-event-1`、`path=knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md`,GET 同 session 返回 1 条记录,`storage.messageFulltextCopied=false`;携带 `message` 全文字段的 POST 返回 `page_ai_openhub_artifact_index_forbidden_fulltext_field`。
|
||||
- 2026-06-26 OpenHub 嵌入态已接 MNote artifact index:`mnoteEmbed.js` 从 `mnoteScope` 解出 `rootUri/workspaceId/pageResourceId`,`SmartQueryPage.jsx` 在 changed files diff metadata 加载后 POST `kind=changed_file` 轻量记录,并在 WeKnora citation/tool output 出现时 POST `kind=citation` 轻量记录;请求字段限定为 `openhubSessionId/kind/providerId/path/citationPayload/rootUri/workspaceId/pageResourceId`,citationPayload 递归去除 `message/messages/content/text/transcript/quote/displayQuote/matched_content` 等全文或证据正文键,不发送 OpenHub message/content/text 全文字段。
|
||||
- 2026-06-26 `node --check scripts/task780-openhub-artifact-index-runtime-smoke.js` 与 `node scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过。smoke 验证 OpenHub 前端静态边界、runtime POST URL/request body 形状、`mnoteScope` 上下文字段、changed_file / citation 轻量 artifact index 记录,以及请求体不含全文字段。
|
||||
|
||||
## 8. P1 WeKnora 知识库页面替换
|
||||
|
||||
- [x] 建立 MNote `mnote_knowledge_bases` 模型:user/workspace/rootUri/name/provider/provider_kb_id/default_tool_enabled。
|
||||
- [x] 迁移/扩展 `mnote_knowledge_sources`:兼容旧 `lightRag*` 字段,新增 provider-neutral 字段。
|
||||
- [x] 知识库列表页复用 WeKnora KB list 体验:名称、描述、文档数、chunk 数、processing 状态、更新时间。
|
||||
- [x] 知识库详情页复用 WeKnora detail 体验:source 列表、状态、reparse/delete、搜索入口。
|
||||
- [x] source 添加入口使用 MNote FileTree/resource picker,不使用 WeKnora 独立文件真相。
|
||||
- [x] 入库前 MNote 校验 allowed roots;文件/文件夹/page resource 转成 WeKnora file/manual/url ingest。
|
||||
- [x] processing timeline 显示 WeKnora `pending/processing/completed/failed`,并同步到 registry 与 FileTree 灯号。
|
||||
- [x] 删除 source 只删除 provider index/registry 映射,不删除本地文件。
|
||||
- [x] browser smoke:创建 KB -> 添加本地文件夹 -> 看到 indexing -> 完成后可搜索。
|
||||
|
||||
P1 WeKnora 知识库模型记录:
|
||||
|
||||
- 2026-06-26 新增 MNote 后端 `mnote_knowledge_bases` runtime registry:`schema=mnote.knowledge_bases.registry.v1`,持久化到 workspace root 下 `.mnote/index/mnote-knowledge-bases.json`,字段包含 `userId/workspaceId/rootUri/name/provider/providerKbId/defaultToolEnabled/sourceCount/chunkCount/status`;`status` 与 `ingest` 会在有 `rootUri` 时返回 `knowledgeBases`。
|
||||
- `mnote_knowledge_sources` 边界通过现有 `.mnote/index/lightrag-source-registry.json` 兼容落地:新增 provider-neutral 字段 `provider/providerStatus/providerSourceId/providerKnowledgeId/providerKnowledgeBaseId`,同时保留旧 `lightRagDocId/lightRagStatus/lightRagFilePath` 兼容字段;读写 registry 时会为旧记录补齐 provider-neutral 字段,不删除旧 registry。
|
||||
- WeKnora ingest 现在写入 source registry 的 provider-neutral 映射,并同步 `mnote_knowledge_bases.sourceCount`;search/open-reference 继续复用 provider ids 与旧 LightRAG/source registry 兼容映射,不破坏已完成 ingest/search/open-reference。
|
||||
- 2026-06-26 `node --check scripts/task781-knowledge-bases-registry-smoke.js`:通过。
|
||||
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task781-knowledge-bases-registry-smoke.js`:通过。验证 status 可读 `mnote_knowledge_bases`、ingest 写 provider-neutral source 字段、search 复用同一 WeKnora KB/source 映射;结果文件 `tmp/task781-knowledge-bases-registry-smoke/result.json`。同一脚本曾因 WeKnora 异步索引窗口在 240s 内未返回新 source 而失败一次,随后重跑通过,说明 search readiness 仍有 provider 异步延迟风险。
|
||||
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task785-weknora-create-kb-folder-ingest-search-smoke.js`:通过。真实 API 创建 WeKnora KB `df7bd813-fef2-40f4-b08d-b47e03a58cea`,ingest 本地文件夹下 2 个 Markdown source,scoped search 命中同一 KB/source 映射;结果文件 `tmp/task785-weknora-create-kb-folder-ingest-search-smoke/result.json`。
|
||||
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task788-weknora-kb-folder-browser-flow-smoke.js`:通过。真实浏览器 UI 创建 WeKnora KB `dc5e35f6-4d0f-4289-ae79-de8f4dac8441`,UI 添加本地文件夹 source,source registry 映射 2 个 source,UI 指定新 KB 检索并展示 source result,API search 复核命中同一 KB/source;`uiCreatesKb=true`、`uiAddsFolder=true`、`browserVerifiesSearch=true`、`apiVerifiesSearch=true`。截图:`tmp/task788-weknora-kb-folder-browser-flow-smoke/weknora-kb-folder-browser-flow.png`。
|
||||
- 2026-06-26 修复 WeKnora KB 创建 payload:新增 `embedding_model_id`,默认 `builtin-mnote-embedding`,可由 `MNOTE_WEKNORA_EMBEDDING_MODEL_ID` / `WEKNORA_EMBEDDING_MODEL_ID` 覆盖;否则新 KB 会长期 processing 且 search 无结果。
|
||||
- 2026-06-26 修复 scoped search:`sourcePaths` 从 registry 推导 `knowledge_ids`,并在 WeKnora 返回后按 source scope 二次过滤,避免同 KB 下其他旧文件污染指定 source 检索。
|
||||
|
||||
## 9. P1 WeKnora 检索替换 LightRAG
|
||||
|
||||
- [~] 在 `rust/crates/mnote-web/src/routes/knowledge_rag.rs` 或拆分模块中抽出 `KnowledgeProvider` boundary。
|
||||
- [x] 新增 `weknora` provider,旧 LightRAG 标为 `lightrag_legacy` 并默认隐藏。
|
||||
- [x] `/api/knowledge-rag/status` 改为 WeKnora health + KB/source registry 状态。
|
||||
- [x] `/api/knowledge-rag/ingest` 改为 WeKnora ingest,写 provider KB / knowledge / source hash。
|
||||
- [x] `/api/knowledge-rag/search` 调 `/api/v1/knowledge-search`;单 KB 调试可走 hybrid-search。
|
||||
- [x] `/api/knowledge-rag/query` 第一阶段用 search results + citations 生成 answer envelope,不启用 WeKnora chat session。
|
||||
- [x] `/api/knowledge-rag/section-context` 改为 WeKnora chunk/list/by-id + MNote locator,不读 LightRAG sidecar。
|
||||
- [~] `/api/knowledge-rag/open-reference` 仅接受 provider ids / open-reference token,回查 registry 后生成 MNote locator。
|
||||
- [x] 映射保留 `content/matched_content/match_type/metadata/chunk_metadata/image_info/parent_chunk_id/sub_chunk_id`。
|
||||
- [x] UI 不把 score 显示为百分比相似度;只显示排序或弱化分值。
|
||||
- [x] 单测:SearchResult -> MNote result/citation/open-reference;缺 registry 映射时降级且不伪造路径。
|
||||
- [x] smoke:WeKnora ingest 本地 md -> MNote search 返回 provider=`weknora` -> open-reference 打开 MNote 文档。
|
||||
|
||||
P1 WeKnora 检索记录:
|
||||
|
||||
- `knowledge_rag.rs` 默认 provider 为 `weknora`,`MNOTE_KNOWLEDGE_PROVIDER=lightrag|lightrag_legacy` 才进入 legacy。
|
||||
- `/api/knowledge-rag/status` 返回 `providerConfig.active/default/legacyFallback/weknora/lightrag`、WeKnora health、legacyHealth、registry。
|
||||
- `/api/knowledge-rag/ingest` 默认 WeKnora provider 时通过 `POST /api/v1/knowledge-bases/:id/knowledge/file` multipart 上传本地授权文件,写回 `providerKnowledgeBaseId/providerKnowledgeId/sourceHash/providerStatus`;缺 KB id 时只登记 `pending_provider_ingest_missing_kb`,不碰 LightRAG staging。
|
||||
- `/api/knowledge-rag/search` 与 `/api/knowledge-rag/query` 调 WeKnora `/knowledge-search`,请求包含 `query + knowledge_base_ids`,按需 `knowledge_ids/match_count`;API key 只在后端注入。
|
||||
- WeKnora chunk mapper 保留 provider ids、raw chunk、content/matched_content、metadata/chunk_metadata/image_info/parent/sub chunks;未命中 registry 时 `filePath=null`、`locatorDegraded=true`,不把 `knowledge_filename` 伪装成本地路径。
|
||||
- `sidebar-page-settings-runtime.js` 已收口 WeKnora 默认 provider、LightRAG legacy fallback、source registry、processing/citation/open-reference 文案;score 只显示 provider 排序分,不再表达为相似度百分比;删除 source 明确不删除本地文件。
|
||||
- `node scripts/task772-weknora-ingest-search-open-reference-e2e.js` 已真实通过:在 allowed root 下创建 `knowledge-rag-fixtures-7-68/task772-weknora-e2e-1782405331456.md`,MNote `/api/knowledge-rag/ingest` 返回 provider=`weknora`、`mappingStatus=provider_mapped`、`providerKnowledgeBaseId=90cc060c-3a16-4146-9d2b-fef8eb2c2d90`、`providerKnowledgeId=78b2c703-6d6a-4fa6-a881-f5ff6cfb769d`;`/api/knowledge-rag/search` 返回 4 条 weknora reference 并保留 provider ids;`/api/knowledge-rag/open-reference` 返回 `filePath=null`、`sourceRootRelativePath` 为 registry 映射路径、`openAction.params.path` 为本地 source path,未把 provider filename 伪造成本地路径。
|
||||
- `/api/knowledge-rag/section-context` 已改为 WeKnora provider chunk 优先:有 `providerChunkId` 时调 `/chunks/by-id/:id`,否则按 `providerKnowledgeId` 调 `/chunks/:knowledge_id`;返回 `providerChunkFetch.attempted/ok/source/fetchedChunks`、`providerChunk` metadata 与 MNote locator,且 `sidecarRead=false`。为适配 WeKnora 异步 parse 状态,section-context 对 WeKnora provider 已允许 `pending/submitted/processing/processed/completed/provider_mapped` 的 registry 映射进入 provider chunk fetch;LightRAG legacy sidecar 仍要求 `processed`。
|
||||
- 2026-06-26 `node scripts/task777-weknora-section-context-smoke.js`:通过。基于 task772 真实 ingest 结果,by-id 命中 `providerChunkId=78f026a5-7bb1-4b82-92f7-ee4f560f5640`,list 命中同一 `providerKnowledgeId=ec461ca5-37f7-471c-b4f7-4ced7be09a01`,两路均返回 `providerChunkFetch.ok=true`、`sidecarRead=false`、文本含 task772 marker。
|
||||
- 2026-06-26 `cargo test -p mnote-web knowledge_rag --lib -- --test-threads=1`:59 passed;`node scripts/task544-weknora-provider-bridge-static-smoke.js`:通过。
|
||||
|
||||
## 10. P1 WeKnora MCP/CLI Tool Bridge
|
||||
|
||||
- [x] 定义 MNote 对 OpenHub/opencode 暴露的工具名:`mnote.weknora.search`、`mnote.weknora.open_reference`、`mnote.weknora.list_sources`、`mnote.weknora.get_source_status`。
|
||||
- [x] 底层第一阶段优先用 Go CLI `weknora mcp serve` 或 MNote HTTP facade 包装同等只读能力。
|
||||
- [x] tool 调用前注入 KB/source allowlist;禁止直接传未授权 `knowledge_base_id`。
|
||||
- [x] tool result 返回 provider ids、quote/matched_content、chunk metadata、MNote open-reference token。
|
||||
- [x] 不把 WeKnora API key 暴露给浏览器或 OpenHub 前端 localStorage。
|
||||
- [x] Python `mcp-server` 的 create/delete/chunk mutation 不默认暴露;如未来开启写能力,必须经过 MNote 权限审批。
|
||||
- [~] smoke:OpenHub AI 通过 WeKnora tool 查询 KB,回答中出现可点击 citation,点击后由 MNote 打开本地页面。当前完成 OpenHub host mode 自动 WeKnora search/tool event + clickable citation bridge;仍不是 opencode 原生 MCP 自动选 tool 的完整闭环。
|
||||
|
||||
P1 WeKnora tool 记录:
|
||||
|
||||
- Hermes tool manifest 与 dispatch 已新增 `mnote.weknora.search/open_reference/list_sources/get_source_status`。
|
||||
- Tool wrapper 要求 `rootUri` 以及 `scope/allowlist/allowedRoots/aiAccessScope/sourcePaths` 之一,否则返回 `mnote_weknora_scope_required`。
|
||||
- 当前通过 MNote HTTP facade 调用 WeKnora;没有把 WeKnora API key 暴露到浏览器/localStorage,也没有默认开放 Python mcp-server 写能力。
|
||||
- 2026-06-26 task782 可写 worker:OpenHub backend 新增 MNote host mode `/api/mnote/tools/call` 只读 facade,通过 MNote 反代受控 headers 与 server-side `X-MNote-Session-Cookie` 调 `/api/hermes/tools/mnote/call`,支持 `mnote.weknora.search/open_reference/list_sources/get_source_status`;OpenHub 不接触 WeKnora API key,也不把 MNote cookie 暴露给前端 localStorage。`stream.py` 在 MNote host mode 下对知识库/资料库/WeKnora/citation/source 类问题先调用 MNote WeKnora search,把结构化 tool result 注入 OpenHub/opencode prompt,并向 OpenHub 前端推送 `mnote.weknora.search` tool event。
|
||||
- 2026-06-26 task782 可写 worker:OpenHub 前端 `AssistantMessage` 从 WeKnora tool output 的 `citations/uiCitations/references` 渲染 `data-mnote-openhub-citation` 可点击 citation,点击经 `postMNoteOpenReference` 发 `mnote:open-reference`;MNote Page AI host 已接受 `openhub-citation` 并复用 document pane open-file bridge 打开本地页面。新增 `scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 覆盖后端 facade、scope/cookie/API key 边界、stream auto tool bridge、citation button 与 postMessage runtime。主控复核:OpenHub `/openapi.json` 已暴露 `/api/mnote/tools/call`,`node scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 通过;边界仍标 partial,不宣称 opencode 原生 MCP 自动选 tool 完成。
|
||||
|
||||
## 11. P1 验收与证据
|
||||
|
||||
- [~] `npm run dev:hot` 或目标启动命令能启动/检查 MNote、OpenHub FastAPI、OpenHub Redis、opencode、WeKnora API、WeKnora CLI/MCP。OpenHub/MNote/opencode/WeKnora API、Redis auth、WeKnora CLI profile/doctor 与 MCP 命令面已探通;stdio MCP handshake 和单命令 dev-hot orchestration 仍作为后续收口。
|
||||
- [x] 用 `mnote.e2e@example.com` 登录 MNote;Page AI 不出现 OpenHub/WeKnora 登录。
|
||||
- [x] Page AI 可发送消息,OpenHub SQLite session/message/history 持久化,刷新恢复。
|
||||
- [x] AI 修改当前 rootUri 内 Markdown,MNote 当前页面刷新后可见。当前证据由 `task774` 真实 opencode 文件修改 + `task779` 浏览器模拟 OpenHub changed-file bridge 刷新组成。
|
||||
- [x] OpenHub Login/Admin/FileManager/KnowledgeManager/GitTimeMachine 非 AI 入口不接管 MNote。
|
||||
- [x] WeKnora 知识库页完成 KB 创建、source 添加、状态显示、检索、citation 回跳。
|
||||
- [x] 旧 LightRAG 默认入口不再出现在主 UI/smoke;legacy fallback 显式标识。
|
||||
- [x] 保存 smoke 输出、关键截图、失败分层日志。
|
||||
|
||||
P1 当前验证证据:
|
||||
|
||||
- `cargo check -p mnote-web`:通过。
|
||||
- `cargo test -p mnote-web knowledge_rag --lib -- --test-threads=1`:59 passed。
|
||||
- `node scripts/task544-weknora-provider-bridge-static-smoke.js`:通过。
|
||||
- `node scripts/task768-page-ai-openhub-host-static-smoke.js`:通过。
|
||||
- `node scripts/task769-weknora-knowledge-settings-ui-static-smoke.js`:通过。
|
||||
- `node scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js`:通过。
|
||||
- `node --check scripts/task773-page-ai-openhub-browser-smoke.js`:通过。
|
||||
- 早期 `node scripts/task773-page-ai-openhub-browser-smoke.js` 只验证到静态 shell,已被后续 React bundle 接入结果覆盖;保留为阶段证据,不作为当前最终状态。
|
||||
- `npm run build`(`/tmp/mnote-openhub-research/OpenHub/smart-query-frontend`):通过;Vite 仍提示主 chunk 超过 500 kB,这是既有体积 warning。
|
||||
- OpenHub React dist 已复制到 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend/static/`,MNote `/page-ai/openhub/ai` 可加载 OpenHub SPA index/assets。
|
||||
- `node scripts/task773-page-ai-openhub-browser-smoke.js`:通过;测试账号真实登录,Page AI OpenHub host drawer/iframe 可见,OpenHub React UI marker 出现,未出现 OpenHub/WeKnora 登录页;`openhubReactConnected=true`、`staticBoundaryOnly=false`,嵌入态未显示 FileManager/KnowledgeManager/GitTimeMachine 等冲突入口。
|
||||
- `curl -H X-MNote-* http://127.0.0.1:18080/api/models`:通过;MNote host mode 返回 66 个真实 opencode 模型,`source=opencode_config_providers`,未使用 mock。
|
||||
- `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过;MNote proxy -> OpenHub `/api/models` -> `/api/query/stream` -> OpenHub SQLite `/api/sessions/{id}/messages` 全链路通过,选用 `opencodego/deepseek-v4-flash`。
|
||||
- `MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过;opencode 修改 MNote rootUri 内测试 Markdown,最终内容含 `status: MNOTE_OPENHUB_FILE_EDIT_OK`,`.git` 未被 OpenHub 创建。
|
||||
- `node --check scripts/task772-weknora-ingest-search-open-reference-e2e.js`:通过。
|
||||
- `node scripts/task772-weknora-ingest-search-open-reference-e2e.js`:通过;当前 shell 未直接暴露 WeKnora env,脚本先输出 `local_weknora_env_not_visible` warning,但继续以 MNote 3000 的 status 和真实 API 调用为准,最终 ingest/search/open-reference 全链路通过。最新主控重跑创建 `knowledge-rag-fixtures-7-68/task772-weknora-e2e-1782408870763.md`,映射到 WeKnora KB `90cc060c-3a16-4146-9d2b-fef8eb2c2d90` 与 knowledge `c6789541-7ad5-47a4-b621-5ef1e2762688`。
|
||||
- `python -m py_compile /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/query.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/session.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/core/auth.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/core/mnote_scope.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/git_snapshot.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/stream.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/task_executor.py`:通过。
|
||||
- `MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=1 PYTHONPATH=/tmp/mnote-openhub-research/OpenHub/smart-query-backend python ...`:通过,确认底层 git 写命令 guard 生效。
|
||||
- `node --test scripts/desktop-hot.test.js`:6 passed。
|
||||
- `git diff --check`:通过。
|
||||
- `codegraph sync .`:完成,报告 synced 38 changed files。
|
||||
- `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过;MNote proxy -> OpenHub changed files endpoint 返回真实 opencode tool-event path,且 OpenHub 前端到 MNote document pane open-file bridge 静态边界通过。
|
||||
- `node scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js`:通过;打开测试 Markdown 后改写 rootUri 内文件并触发 OpenHub changed-file bridge,document pane 可见刷新到新正文,诊断显示 `eventBusSource=openhub_changed_file_bridge`。
|
||||
- `node --check scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过。
|
||||
- `node scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过;验证 OpenHub embed changed_file / citation 轻量 artifact index POST 形状,且不含 message/content/text 等全文字段。
|
||||
- 外部服务真实探针:`/api/page-ai/openhub/status` 在配置 WeKnora API key 后返回 `status=ready`,OpenHub FastAPI `127.0.0.1:18080` ready,opencode `127.0.0.1:4096` ready,WeKnora public `/health` ready,OpenHub `/openapi.json` 暴露 `/api/mnote/tools/call`,Redis `AUTH + PING` 返回 `+OK/+PONG`,WeKnora CLI `profile add + auth login --with-token + doctor --no-cache` 通过,`go run . mcp serve --help` 通过。stdio MCP handshake 未作为最终通过项,因此 dev-hot/CLI/MCP 全链路仍 partial。
|
||||
- 2026-06-26 task783/task784 UI 增量:`sidebar-page-settings-runtime.js` 在 WeKnora 设置面板增加 KB summary(KB/source/provider docs/processing)、本地文件/文件夹 source picker 边界标记、source row 与 search result 的 `open-source-reference` 控件;`sidebar-tree-runtime.js` 接线该控件到 MNote document pane `openPrimaryDocument`,仍复用 `/api/knowledge-rag/status/ingest/search/delete-source`,未改后端 `knowledge_rag.rs`。`node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js`、`node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`、`node --check scripts/task783-weknora-kb-settings-ui-static-smoke.js`、`node --check scripts/task784-weknora-kb-settings-browser-smoke.js`、`node scripts/task783-weknora-kb-settings-ui-static-smoke.js`、`node scripts/task769-weknora-knowledge-settings-ui-static-smoke.js` 均通过。真实 browser smoke 使用临时 3018 MNote 实例与已有 task772 WeKnora 入库结果:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task784-weknora-kb-settings-browser-smoke.js` 通过,截图 `tmp/task784-weknora-kb-settings-browser-smoke/weknora-kb-settings.png`;验证 KB summary 包含 KB `90cc060c-3a16-4146-9d2b-fef8eb2c2d90`,source row/search result/open-reference/delete-preserve-local-file 控件可见。仍 partial:未创建新的 KB;未真实点击 open-reference 后断言文档 pane 跳转;未完成 checklist 第 8 节“创建 KB -> 添加本地文件夹 -> indexing -> 完成后搜索”的完整链路。
|
||||
- 2026-06-26 task788 UI 完整链路增量:`sidebar-page-settings-runtime.js` 新增 WeKnora KB select、创建 KB 控件,ingest/search 带当前选中 `providerKnowledgeBaseId`;`sidebar-tree-runtime.js` 接线 create-kb 与 KB select change。主控复核 `node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js`、`node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`、`node --check scripts/task788-weknora-kb-folder-browser-flow-smoke.js` 通过;临时当前构建 MNote `3022` 上 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task788-weknora-kb-folder-browser-flow-smoke.js` 通过,UI 创建 KB `dc5e35f6-4d0f-4289-ae79-de8f4dac8441`、UI 添加文件夹 source、UI 指定新 KB 检索、API search 复核均通过。
|
||||
- 2026-06-26 用户纠正后知识库页面替换增量:原“资料库问答/资料源管理”小面板不符合 7-68,已改为 WeKnora 风格“知识库列表 + 知识库详情/文档管理”页面。`sidebar-page-settings-runtime.js` 补入 KB 面包屑、Documents/Wiki/Graph/Search 页签、source/tag 侧栏、文档搜索/筛选、添加文档/文件夹、列表/网格切换、processing/status 与 open-reference 控件;`main.css` 补齐对应布局;新增 `scripts/task790-weknora-kb-page-experience-static-smoke.js` 与 `scripts/task79x-weknora-kb-page-browser-smoke.js`。
|
||||
- 2026-06-26 纠正后复核:`node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js`、`node --check scripts/task790-weknora-kb-page-experience-static-smoke.js`、`node --check scripts/task79x-weknora-kb-page-browser-smoke.js`、`node scripts/task783-weknora-kb-settings-ui-static-smoke.js`、`node scripts/task789-weknora-kb-page-ui-reference-static-smoke.js`、`node scripts/task790-weknora-kb-page-experience-static-smoke.js`、`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task79x-weknora-kb-page-browser-smoke.js` 均通过。浏览器结果确认 `openedReplacementPanel=true`、`notOldSimpleSettingsPanel=true`、`hasKnowledgeBaseListCards=true`、`hasDetailStructure=true`、`hasDocumentTabs=true`、`hasSearchAndFilter=true`;截图 `tmp/task79x-weknora-kb-page-browser-smoke/weknora-kb-page-browser-smoke.png`。
|
||||
- 2026-06-26 Page AI 截图复核:`MNOTE_UI_BASE_URL=http://127.0.0.1:3022 node scripts/task773-page-ai-openhub-browser-smoke.js` 通过,截图 `tmp/7-68-runtime/page-ai-openhub-browser.png`;OpenHub React UI marker 可见,`openhubReactConnected=true`、`staticBoundaryOnly=false`,未出现 OpenHub/WeKnora 登录页或 FileManager/KnowledgeManager/GitTimeMachine 冲突入口。
|
||||
- 2026-06-26 `BASE_URL=http://127.0.0.1:3021 node scripts/task786-openhub-history-refresh-browser-smoke.js`:通过;OpenHub session/message/history 刷新恢复已由浏览器和 API 双重验证,结果文件 `tmp/task786-openhub-history-refresh-browser-smoke/result.json`。
|
||||
- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task787-weknora-default-lightrag-legacy-smoke.js`:通过;静态扫描 33 个主 UI/smoke 文件,`violations=[]`,status API 的默认/active provider 为 WeKnora,LightRAG 仅作为显式 legacy fallback。
|
||||
|
||||
## 12. P2 后续优化
|
||||
|
||||
- [ ] 若需要开放 OpenHub Admin/tool/model 管理,先做 MNote role -> OpenHub admin scope 映射设计。
|
||||
- [ ] 若需要完整 MCP 管理 UI,再单独设计 OpenHub/MNote MCP 管理面,不混入首版嵌入。
|
||||
- [ ] 若需要 WeKnora chat/agent-chat,作为 OpenHub tool 内部 provider call,不变更 Page AI session 真相。
|
||||
- [ ] 若需要版本恢复,走 MNote DocumentBuffer/watcher/显式用户确认策略,不恢复 OpenHub GitTimeMachine 写链。
|
||||
@@ -4,6 +4,8 @@
|
||||
Owner:07-ai / mnote-web / control-plane / knowledge-provider
|
||||
日期:2026-06-25
|
||||
|
||||
执行说明:本文是 Page AI / OpenHub / WeKnora 深度融合的当前主设计,不表示代码已完成。具体落地按本文第 10 节与配套清单 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md` 执行;若实现中发现源码能力与本文假设不一致,优先遵循“保留原系统已完成能力,只在产品真相冲突处做最小胶水/嫁接/裁剪”的总原则。
|
||||
|
||||
## 0. 结论先行
|
||||
|
||||
MNote Page AI 不应再沿“官方 opencode WebUI iframe”或“自研简陋聊天框”继续堆功能。新的主路线是:
|
||||
@@ -11,22 +13,22 @@ MNote Page AI 不应再沿“官方 opencode WebUI iframe”或“自研简陋
|
||||
```text
|
||||
MNote 当前文档页 / workspace / auth / resource truth
|
||||
├─ Page AI UI:嵌入 OpenHub AI 界面(只暴露 AI 面板能力)
|
||||
├─ OpenHub backend:运行 OpenHub FastAPI + Redis + OpenHub session/skill/MCP/permission
|
||||
├─ Agent runtime:OpenHub FastAPI 调用 opencode serve / opencodego provider
|
||||
├─ Knowledge provider:WeKnora,通过 OpenHub MCP/CLI/skill 工具调用
|
||||
├─ OpenHub backend:运行 OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission
|
||||
├─ Agent runtime:OpenHub FastAPI 调用 opencode serve / opencode provider
|
||||
├─ Knowledge provider:WeKnora,通过 MNote 注册给 OpenHub/opencode 的 MCP/CLI/skill 工具调用
|
||||
└─ MNote boundary:统一登录授权、workspace/rootUri scope、文件打开、citation 回跳
|
||||
```
|
||||
|
||||
核心取舍:
|
||||
|
||||
总原则:**各部分尽量保持原有已完成能力,只有发生产品真相冲突时才做最小胶水/嫁接/裁剪**。OpenHub 保持 AI 面板、FastAPI、Redis、session、skill/MCP、opencode client;WeKnora 保持知识库底座、MCP/CLI/API;MNote 保持 workspace/auth/resource tree/document pane/source registry。MNote 不重写 OpenHub/WeKnora 已有能力,只在登录态、workspace scope、文件打开、citation 回跳、禁用无关入口这些冲突点上做最小移植。
|
||||
总原则:**各部分尽量保持原有已完成能力,只有发生产品真相冲突时才做最小胶水/嫁接/裁剪**。OpenHub 保持 AI 面板、FastAPI、Redis、SQLite session/message、skill/tool permission、opencode client;WeKnora 保持知识库底座、MCP/CLI/API;MNote 保持 workspace/auth/resource tree/document pane/source registry。MNote 不重写 OpenHub/WeKnora 已有能力,只在登录态、workspace scope、文件打开、citation 回跳、禁用无关入口这些冲突点上做最小移植。
|
||||
|
||||
- **OpenHub 负责 Page AI 界面与多用户 AI 运行栈**:嵌入 OpenHub AI 面板,运行 FastAPI/Redis,使用 OpenHub 的用户隔离、session、skill、agent/MCP、tool permission、opencode 调用链;FileManager/KnowledgeManager/Admin/Login 等非 AI 入口掐断或转接。
|
||||
- **OpenHub 负责 Page AI 界面与多用户 AI 运行栈**:嵌入 OpenHub AI 面板,运行 FastAPI/Redis,使用 OpenHub 的用户隔离、session、skill、agent/tool permission、opencode 调用链;MCP 由 MNote/WeKnora/opencode 工具配置承接,不把“OpenHub 已有完整 MCP 管理面”当成已确认事实;FileManager/KnowledgeManager/Admin/Login 等非 AI 入口掐断或转接。
|
||||
- **WeKnora 负责知识库底座、知识库页面参考实现和 MCP/CLI/API 工具能力**:解析、chunk、混合检索、RAG 引用、Wiki/图谱、知识库管理 API;知识库页面优先复用 WeKnora 的 KB list/detail/upload/status 体验,但不让 WeKnora 接管 MNote 文件真相。
|
||||
- **MNote 负责宿主真相与冲突胶水**:登录、用户、workspace/rootUri、resource tree、页面打开、allowed roots、session binding、source registry、citation/open-reference;不重写 OpenHub/WeKnora 已有主功能。
|
||||
- **opencode 负责 agent 执行**:文件编辑、diff、工具审批、模型调用。
|
||||
|
||||
不得把 OpenHub 登录入口、OpenHub FileManager、OpenHub KnowledgeManager、WeKnora RBAC/前端变成 MNote 的用户/文件/知识库真相。OpenHub AI session/message/skill/MCP/permission/FastAPI/Redis 可以作为 Page AI 运行真相,但必须受 MNote 派生的用户与 workspace scope 隔离;WeKnora 保持知识库底座真相,MNote 只做展示、授权和回跳映射。
|
||||
不得把 OpenHub 登录入口、OpenHub FileManager、OpenHub KnowledgeManager、WeKnora RBAC/前端变成 MNote 的用户/文件/知识库真相。OpenHub AI session/message/skill/tool permission/FastAPI/Redis 可以作为 Page AI 运行真相,但必须受 MNote 派生的用户与 workspace scope 隔离;WeKnora 保持知识库底座真相,MNote 只做展示、授权和回跳映射。
|
||||
|
||||
## 1. 背景与当前状态
|
||||
|
||||
@@ -44,7 +46,7 @@ MNote 当前文档页 / workspace / auth / resource truth
|
||||
本设计替代 `7-65` 的官方 opencode iframe 产品主线,并收敛 `7-67` 的 OpenHub 初步融合设想。核心不是重写三套系统,而是保留各自已有能力,只对冲突点做最小胶水:
|
||||
|
||||
1. 登录/用户/权限冲突。
|
||||
2. WeKnora 知识库底座、MNote 展示层、OpenHub MCP/skill/tool 调用边界。
|
||||
2. WeKnora 知识库底座、MNote 展示层、MNote 注册给 OpenHub/opencode 的 CLI/MCP/skill/tool 调用边界。
|
||||
3. 页面/文件打开、changed files、citation 回跳边界。
|
||||
|
||||
## 2. 三方职责边界
|
||||
@@ -52,16 +54,16 @@ MNote 当前文档页 / workspace / auth / resource truth
|
||||
| 能力 | MNote | OpenHub | WeKnora | 取舍 |
|
||||
|---|---|---|---|---|
|
||||
| 登录/用户 | MNote SQLite control-plane、`mnote_session` | OpenHub JWT/localStorage 禁用或后端注入;使用 MNote 派生 user/workspace | WeKnora tenant/RBAC/API key | MNote 是唯一登录入口;OpenHub 运行态按 MNote 用户/工作区派生 |
|
||||
| workspace/rootUri | MNote local workspace、rootUri 授权 | OpenHub user workspace path / session scope / skill scope / MCP scope | WeKnora tenant/KB | rootUri 归 MNote;OpenHub 的 workspace directory 由 MNote 授权 rootUri 派生 |
|
||||
| Page AI UI | MNote sidebar host / iframe/proxy shell | OpenHub AI 界面 | WeKnora Web UI 不嵌入 | 嵌入 OpenHub AI 面板;隐藏或掐断非 AI 页面入口 |
|
||||
| session/message | MNote 只做 host binding / ownership index | OpenHub conversation/session tables + Redis/cache | WeKnora chat sessions 暂弃用 | OpenHub session/message 是 Page AI 真相;MNote 不复制消息全文 |
|
||||
| agent runtime | MNote 启停/健康检查/反代边界 | OpenHub FastAPI + Redis + opencode client | WeKnora MCP/CLI/API,agent-chat 暂弃用 | 运行 OpenHub 后端;由 OpenHub 调 opencode;MNote 不重写 opencode client |
|
||||
| 知识库 UI | MNote shell / FileTree 灯号 / citation 回跳 | OpenHub KnowledgeManager 掐断或跳转 MNote | 复用 WeKnora KB list/detail/upload/status 页面能力 | 用 WeKnora 知识库页面替换 MNote 简陋页,但本地文件和授权仍归 MNote |
|
||||
| 检索/RAG | MNote source registry / citation 回跳 / scope 集合定义 | OpenHub AI 通过 MCP/skill/tool 调 WeKnora | WeKnora hybrid/vector/graph/chunk | 知识库是 MNote 授权文件/文件夹集合的索引视图;WeKnora 是唯一索引与查询 provider |
|
||||
| workspace/rootUri | MNote local workspace、rootUri 授权 | OpenHub user workspace path / session scope / skill scope / tool scope | WeKnora tenant/KB | rootUri 归 MNote;OpenHub 的 workspace directory 由 MNote 授权 rootUri 派生 |
|
||||
| Page AI UI | MNote sidebar host / iframe/proxy shell | OpenHub AI 界面 | WeKnora 不进入 Page AI 对话壳 | 嵌入 OpenHub AI 面板;隐藏或掐断非 AI 页面入口 |
|
||||
| session/message | MNote 只做 host binding / ownership index | OpenHub SQLite conversation/session/message tables;Redis 只作缓存/临时态 | WeKnora chat sessions 暂弃用 | OpenHub session/message 是 Page AI 真相;MNote 不复制消息全文 |
|
||||
| agent runtime | MNote 启停/健康检查/反代边界 | OpenHub FastAPI + Redis + opencode client | WeKnora CLI/MCP/API,agent-chat 暂弃用 | 运行 OpenHub 后端;由 OpenHub 调 opencode;MNote 不重写 opencode client |
|
||||
| 知识库 UI | MNote shell / FileTree 灯号 / citation 回跳 | OpenHub KnowledgeManager 掐断或跳转 MNote | 复用 WeKnora KB list/detail/upload/status 页面体验 | 用 MNote shell + WeKnora KB adapter 替换 MNote 简陋页,但本地文件和授权仍归 MNote |
|
||||
| 检索/RAG | MNote source registry / citation 回跳 / scope 集合定义 | OpenHub AI 通过 MNote 注册的 CLI/MCP/skill/tool 调 WeKnora | WeKnora hybrid/vector/graph/chunk | 知识库是 MNote 授权文件/文件夹集合的索引视图;WeKnora 是唯一索引与查询 provider |
|
||||
| 文件打开 | MNote document pane/resource tab/FileTree,相当于 FileManager | OpenHub FileManager 掐断或跳转 MNote | WeKnora 不负责页面打开 | OpenHub AI 回复中的 path/citation 点击回 MNote 打开页面 |
|
||||
| 权限审批 | MNote allowed roots + workspace grants + scope 注入 | OpenHub tool/model/skill/MCP permissions | WeKnora RBAC | MNote 提供授权边界;OpenHub 按原生权限规范执行;WeKnora RBAC 作 provider 防线 |
|
||||
| 权限审批 | MNote allowed roots + workspace grants + scope 注入 | OpenHub tool/model/skill permissions;MCP 走 opencode/WeKnora 工具配置 | WeKnora RBAC | MNote 提供授权边界;OpenHub 按原生权限规范执行;WeKnora RBAC 作 provider 防线 |
|
||||
| Git / snapshot / restore | MNote watcher、buffer、用户显式保存/版本策略 | OpenHub Git snapshot 默认关闭 | 无关 | 第一阶段禁用 Git snapshot/restore,避免污染 local-first workspace |
|
||||
| Redis/cache/queue | MNote 不存 Page AI 消息真相 | OpenHub Redis/cache/queue 由 OpenHub stack 管理 | WeKnora 如有内部 Redis 由 WeKnora stack 管理 | Redis 归各自服务栈;不作为 MNote 权限/文件/知识库真相 |
|
||||
| Redis/cache | MNote 不存 Page AI 消息真相 | OpenHub Redis 主要用于 token/rate-limit/cache/临时态;消息真相在 SQLite | WeKnora Redis/Asynq/Langfuse 由 WeKnora stack 管理 | Redis 归各自服务栈;不作为 MNote 权限/文件/知识库真相 |
|
||||
|
||||
## 3. 登录、OpenHub 隔离与 WeKnora 授权融合
|
||||
|
||||
@@ -71,12 +73,12 @@ MNote 当前文档页 / workspace / auth / resource truth
|
||||
|
||||
```text
|
||||
MNote 登录态 / user_id / workspace grants
|
||||
├─ OpenHub 派生隔离上下文:openhub_user_key + workspace/runtime/session/skill/MCP scope
|
||||
├─ OpenHub 派生隔离上下文:openhub_user_key + workspace/runtime/session/skill/tool scope
|
||||
└─ WeKnora 派生知识库上下文:已授权 workspace/rootUri -> provider tenant/profile/KB/source registry
|
||||
```
|
||||
|
||||
- **MNote 负责入口认证与授权判断**:当前用户是谁、能访问哪些 workspace/rootUri、能读写哪些 source。
|
||||
- **OpenHub 需要 per-user / per-workspace 隔离**:session、message、skill、MCP、tool permission、opencode directory、changed files 都必须绑定 MNote 用户与 workspace;不能所有 MNote 用户共用一个 OpenHub runtime identity。
|
||||
- **OpenHub 需要 per-user / per-workspace 隔离**:session、message、skill、tool permission、opencode directory、changed files 都必须绑定 MNote 用户与 workspace;MCP/tool scope 由 MNote 注册的 WeKnora/opencode 工具配置体现;不能所有 MNote 用户共用一个 OpenHub runtime identity。
|
||||
- **WeKnora 是唯一知识库底座,并通过 MCP / CLI / API 暴露给 OpenHub/opencode**:它只接收 MNote 已授权 workspace 的文件/文件夹集合 ingest/search/query 或工具调用;知识库可见性本身依赖 MNote 的 source registry 和 allowed roots,WeKnora 用户认证可以保持简单。
|
||||
- **WeKnora RBAC/API key 是 provider 防线**:不承担 MNote 产品层用户隔离,不把 WeKnora tenant/user 反向暴露成 MNote 登录体系。
|
||||
|
||||
@@ -85,18 +87,18 @@ MNote 登录态 / user_id / workspace grants
|
||||
OpenHub 和 WeKnora 的用户模型对 MNote 的影响不同:
|
||||
|
||||
- OpenHub 前端会使用 `auth_token` / JWT / localStorage,并在 401 后跳转到 `/login`。
|
||||
- OpenHub 后端还有会话、消息、skill、MCP、tool/model permission、workspace path 和 Git snapshot 等用户相关状态。
|
||||
- OpenHub 后端还有会话、消息、skill、tool/model permission、workspace path 和 Git snapshot 等用户相关状态;MCP 工具面由 MNote/WeKnora/opencode 配置承接。
|
||||
- WeKnora 有 tenant RBAC、Owner/Admin/Contributor/Viewer、共享空间与 API Key,但 MNote 的知识库使用场景主要来自“用户已授权 workspace/rootUri”。
|
||||
- MNote 已有 SQLite control-plane auth、`mnote_session` cookie、测试账号与 local workspace 授权。
|
||||
|
||||
如果直接嵌入 OpenHub 或 WeKnora Web UI,会出现三套登录入口、三套用户 id、三套权限判断;但如果把 OpenHub 也降成“无用户共享 runtime”,又会让 session、skill、MCP、工具审批和文件变更串用户。
|
||||
如果直接嵌入 OpenHub 或 WeKnora Web UI,会出现三套登录入口、三套用户 id、三套权限判断;但如果把 OpenHub 也降成“无用户共享 runtime”,又会让 session、skill、工具审批、WeKnora tool scope 和文件变更串用户。
|
||||
|
||||
### 3.3 决策
|
||||
|
||||
- MNote 是唯一**前端登录入口**和产品层授权入口。
|
||||
- OpenHub 不保留自己的 Login 页面、JWT/localStorage 登录跳转,但 MNote boundary 必须为每个 MNote 用户派生 OpenHub runtime identity。
|
||||
- OpenHub 派生 identity 至少包含:`mnote_user_id`、`workspace_id`、`root_uri`、`openhub_user_key`、`opencode_session_scope`、`skill_scope`、`mcp_scope`、`tool_permission_scope`。
|
||||
- OpenHub session/message/skill/MCP/tool permission 不得跨 `mnote_user_id + workspace_id/root_uri` 共享。
|
||||
- OpenHub 派生 identity 至少包含:`mnote_user_id`、`workspace_id`、`root_uri`、`openhub_user_key`、`opencode_session_scope`、`skill_scope`、`tool_permission_scope`、`weknora_tool_scope`。
|
||||
- OpenHub session/message/skill/tool permission 不得跨 `mnote_user_id + workspace_id/root_uri` 共享;MCP/tool 配置不得绕过 MNote 注入的 KB/source allowlist。
|
||||
- WeKnora 使用 MNote 后端服务 API key 或受控 profile 调用;前端不直接持有 WeKnora API key。
|
||||
- WeKnora KB/source 由 MNote 的 workspace grants、source registry、allowed roots 决定;WeKnora tenant/RBAC 只作为 provider 内部防线。
|
||||
|
||||
@@ -110,8 +112,8 @@ MNote user_id + workspace_id/rootUri
|
||||
-> OpenHub scopes:
|
||||
session_scope = user_id + workspace_id + rootUri + page_resource_id
|
||||
skill_scope = user_id + workspace_id + rootUri
|
||||
mcp_scope = user_id + workspace_id + allowed_roots
|
||||
tool_permission_scope = user_id + workspace_id + rootUri
|
||||
weknora_tool_scope = user_id + workspace_id + allowed_kb_ids/source_ids
|
||||
-> WeKnora provider profile: mnote-local 服务 API key
|
||||
-> WeKnora KB: mnote-{workspace_id}-{purpose}
|
||||
-> MNote source registry 记录 provider KB / knowledge / chunk 映射
|
||||
@@ -128,7 +130,7 @@ MNote user_id + workspace membership
|
||||
-> 所有 WeKnora 结果仍由 MNote source registry / allowed roots 二次过滤
|
||||
```
|
||||
|
||||
不要在第一阶段为每个 MNote 用户强行同步 WeKnora RBAC;这会放大生命周期与权限同步复杂度。相反,第一阶段应优先保证 OpenHub 派生上下文隔离,因为 Page AI 的 session、skill、MCP、工具审批和文件变更都直接依赖 MNote 登录态。
|
||||
不要在第一阶段为每个 MNote 用户强行同步 WeKnora RBAC;这会放大生命周期与权限同步复杂度。相反,第一阶段应优先保证 OpenHub 派生上下文隔离,因为 Page AI 的 session、skill、tool permission、WeKnora tool scope 和文件变更都直接依赖 MNote 登录态。
|
||||
|
||||
## 4. OpenHub Session / Message 真相
|
||||
|
||||
@@ -144,7 +146,7 @@ MNote Page AI 面板
|
||||
-> WeKnora 不参与 Page AI 会话真相
|
||||
```
|
||||
|
||||
- **OpenHub session 是 Page AI session 真相**:消息、turn、tool call、skill/MCP 状态、history、retry、visible/hidden 等语义以 OpenHub conversation/session 模型为准。
|
||||
- **OpenHub session 是 Page AI session 真相**:消息、turn、tool call、skill/tool 状态、WeKnora tool call 状态、history、retry、visible/hidden 等语义以 OpenHub conversation/session 模型为准。
|
||||
- **MNote 不复制消息主存储**:MNote 只需要保存 `mnote_user/workspace/rootUri/page_resource_id -> openhub_session_id/opencode_session_id` 的绑定,以及 changed file / citation 回跳所需的轻量索引。
|
||||
- **WeKnora 不产生会话冲突**:当前 WeKnora 主要作为 MCP/CLI/知识库工具 provider;`knowledge-chat` / `agent-chat` 暂时弃用,不纳入 Page AI 主链,因此不设计 `knowledge_session_id`。
|
||||
|
||||
@@ -202,7 +204,7 @@ openhub_workspace_key = stable_hash(workspace_id, root_uri)
|
||||
|
||||
- 同一用户同一页面可恢复最近 active OpenHub session。
|
||||
- 切换 rootUri 或 workspace 必须新开 OpenHub session;旧 session 标记 stale 或 archived。
|
||||
- 不同 MNote 用户不得共享同一个 OpenHub session、skill scope、MCP scope 或 tool permission scope。
|
||||
- 不同 MNote 用户不得共享同一个 OpenHub session、skill scope、tool permission scope 或 WeKnora tool scope。
|
||||
- MNote boundary 对 OpenHub session 的读写必须先校验 `mnote_session` 与 binding ownership。
|
||||
|
||||
### 4.5 WeKnora session policy
|
||||
@@ -234,7 +236,7 @@ WeKnora 应承担 MNote 知识库 provider:
|
||||
- 知识库类型:以 WeKnora `KnowledgeBase.Type` 和 FAQ 配置为准;Wiki/图谱属于 WeKnora Wiki mode / graph 能力,不能未经接口枚举直接当作 KB type 写死。
|
||||
- 导入:文件、URL、Markdown/手工知识、外部数据源。
|
||||
- 文档处理:chunk、OCR/VLM/ASR、图谱抽取、问题生成、reparse。
|
||||
- 检索:优先对接 `POST /api/v1/knowledge-search`,或按 KB 维度对接 hybrid-search;返回分数为融合排序分(如 RRF),不能当原始相似度解释。
|
||||
- 检索:多 KB / 指定 knowledge 检索优先对接 `POST /api/v1/knowledge-search`;单 KB 调试或 CLI/MCP 可走 `POST /api/v1/knowledge-bases/:id/hybrid-search`;返回分数按排序分处理,不能按百分比或原始相似度解释。
|
||||
- 问答:`POST /api/v1/knowledge-chat/:session_id`、`POST /api/v1/agent-chat/:session_id` SSE 作为后续可选 provider 能力;第一阶段 Page AI 主链暂弃用,不产生主会话真相。
|
||||
- 权限:tenant RBAC / shared organization 作内部防线。
|
||||
|
||||
@@ -279,7 +281,7 @@ trait KnowledgeProvider {
|
||||
- `/api/knowledge-rag/search`:调用 WeKnora `/api/v1/knowledge-search` 或 KB 级 hybrid-search,返回 MNote `search_results.v1` 兼容结构。
|
||||
- `/api/knowledge-rag/query`:不再调用 LightRAG `/query/data`;第一阶段用 WeKnora search result 生成带 citations/references 的 query result。
|
||||
- `/api/knowledge-rag/section-context`:不再读 LightRAG sidecar blocks;改为基于 WeKnora chunk / source registry / 本地文件 locator 构造上下文。
|
||||
- 排序与阈值:WeKnora score 是 RRF 融合分,不能沿用 LightRAG 相似度阈值和旧 rank 解释。
|
||||
- 排序与阈值:WeKnora score 是 provider 排序/融合分,不能沿用 LightRAG 相似度阈值、百分比相似度或旧 rank 解释。
|
||||
- 引用映射:WeKnora `knowledge_id` / `chunk_id` / `knowledge_base_id` 只用于回查 registry,不能直接作为 MNote 文件路径。
|
||||
|
||||
WeKnora search result 到 MNote registry 的最低字段映射:
|
||||
@@ -288,17 +290,21 @@ WeKnora search result 到 MNote registry 的最低字段映射:
|
||||
|---|---|---|
|
||||
| `id` | `providerChunkId` | WeKnora chunk id |
|
||||
| `knowledge_id` | `providerKnowledgeId` | 回查 registry 的主键之一 |
|
||||
| `knowledge_base_id` | `providerKnowledgeBaseId` | provider KB 映射 |
|
||||
| `knowledge_base_id` | `providerKnowledgeBaseId` | provider KB 映射;`knowledge-search` 与部分 CLI 输出字段覆盖不完全时从请求 scope/registry 补齐 |
|
||||
| `content` / `matched_content` | `quote` / `matchedText` | citation 文本来源;FAQ/相似问命中优先保留 matched_content |
|
||||
| `chunk_index` | `chunkIndex` | 可用于同一 knowledge 内排序/定位 |
|
||||
| `start_at` / `end_at` | `providerOffsets` | 只能作为 provider 内偏移,不能直接当 Markdown 行号 |
|
||||
| `knowledge_filename` | `providerDisplayName` | 只用于显示,不能当本地路径真相 |
|
||||
| `knowledge_source` / `knowledge_channel` | `providerSourceMeta` | 用于辅助映射和审计 |
|
||||
| `match_type` | `providerMatchType` | 区分 vector/keyword/hybrid/enrichment/direct 等命中渠道 |
|
||||
| `parent_chunk_id` / `sub_chunk_id` | `providerChunkHierarchy` | 父子 chunk 与上下文扩展 |
|
||||
| `metadata` / `chunk_metadata` / `image_info` | `providerMetadata` | 图片/VLM、FAQ、自定义元数据与定位诊断 |
|
||||
|
||||
`sourcePath`、`lineStart`、`lineEnd`、`mnoteResourceId`、`openReference` 必须由 MNote registry / locator 派生;WeKnora 未返回时不能伪造。
|
||||
|
||||
### 5.4 知识库定义、页面选择与 WeKnora Tool Bridge
|
||||
|
||||
这里不应设计 `OpenHub-compatible /knowledge/*`,也不应把 OpenHub `KnowledgeManager.jsx` 作为第一阶段知识库页。知识库页面建议复用 WeKnora 的 `KnowledgeBaseList.vue` / `KnowledgeBase.vue` / 上传与 processing timeline 组件,作为 MNote 知识库展示层的实现。当前边界是:
|
||||
这里不应设计 `OpenHub-compatible /knowledge/*`,也不应把 OpenHub `KnowledgeManager.jsx` 作为第一阶段知识库页。知识库页面建议复用 WeKnora 的 `KnowledgeBaseList.vue` / `KnowledgeBase.vue` / `KnowledgeBaseEditorModal.vue` / 上传与 processing timeline 组件体验,作为 MNote 知识库展示层的实现。当前边界是:
|
||||
|
||||
```text
|
||||
MNote Knowledge UI(复用 WeKnora KB list/detail/upload/status 体验)
|
||||
@@ -306,7 +312,7 @@ MNote Knowledge UI(复用 WeKnora KB list/detail/upload/status 体验)
|
||||
-> WeKnora ingest / search / status / open-reference
|
||||
|
||||
MNote Page AI / OpenHub-style session
|
||||
-> opencode 按 MCP/skill/tool 规范自行决定 tool call
|
||||
-> opencode 按 skill/tool/MCP 规范自行决定 tool call;MNote 只提供受限工具配置和 open-reference bridge
|
||||
-> 已注册的 `mnote.weknora.*` MCP/CLI/API tool
|
||||
-> WeKnora search/query
|
||||
-> tool result 回 OpenHub session
|
||||
@@ -316,9 +322,10 @@ MNote Page AI / OpenHub-style session
|
||||
|
||||
- **知识库不是新的文件真相**:真相永远是 MNote 授权 rootUri 下的本地文件/文件夹/page resource;知识库是这些 source 的命名集合、索引状态和检索配置。
|
||||
- **唯一知识库底座是 WeKnora**:OpenHub 自带 knowledge tables、KnowledgeManager 页面和 prompt stuffing 知识库第一阶段全部不使用。
|
||||
- **知识库页面选择 WeKnora**:复用 WeKnora 的 KB list/detail/upload/status/reparse/processing timeline 体验来替换 MNote 简陋知识库页;MNote 外壳负责登录、workspace、source registry、FileTree 灯号和 citation 回跳。
|
||||
- **知识库页面选择 WeKnora 体验,但不直接接管 MNote shell**:第一阶段采用 `MNote shell + WeKnora KB adapter`。优先抽取或复刻 WeKnora `KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue` 的交互和数据模型;不 iframe WeKnora 全量产品前端,不暴露 WeKnora 登录/租户切换/独立文件真相。
|
||||
- **OpenHub 不管理知识库页面**:OpenHub 只在对话过程中通过 MCP/CLI/API tool 调用 WeKnora,tool result 进入 OpenHub session。
|
||||
- **MNote tool facade 是注册与权限边界**:OpenHub/opencode 不能直接持有 WeKnora API key,也不能绕过 MNote allowed roots / source registry;是否调用工具、如何组织 tool call 由 OpenHub/opencode 自己判断。
|
||||
- **WeKnora MCP surface 第一阶段选择 Go CLI `weknora mcp serve` 或 MNote facade 包装后的等价只读工具面**:该 surface 当前是手工维护的只读工具集,更适合先挂给 OpenHub/opencode。Python `mcp-server` 暴露 create/delete/chunk mutation 等更宽能力,第一阶段只作为参考,不默认接入。
|
||||
|
||||
第一阶段需要的接口不是 OpenHub-compatible knowledge API,而是三类接口:
|
||||
|
||||
@@ -345,9 +352,34 @@ OpenHub/opencode tool facade:
|
||||
|
||||
WeKnora 页面 adapter 可以复用 WeKnora 前端组件/交互,但数据入口必须先经过 MNote source registry 与 allowed roots;`mnote.weknora.*` 可以底层走 WeKnora MCP、CLI 或 HTTP API,但对 OpenHub 暴露的合同必须是 MNote 权限过滤后的 tool contract。
|
||||
|
||||
推荐页面复用方式:
|
||||
|
||||
1. **首选:MNote adapter 页面复刻 WeKnora 知识库体验**。在 MNote 前端保留统一路由、auth、workspace 和 FileTree/resource picker;后端通过 MNote canonical API 转接 WeKnora KB/doc/status/search。成本高于 iframe,但冲突最少。
|
||||
2. **可选:抽取 WeKnora Vue 组件进入独立 adapter bundle**。仅抽知识库列表、详情、上传、状态时间线组件;替换其 auth/client/router/store,数据仍走 MNote 后端。
|
||||
3. **不选:iframe WeKnora 全量前端**。会带来 WeKnora 登录、租户切换、独立上传文件真相和打开页面冲突,只能作为调试入口,不作为产品主线。
|
||||
|
||||
知识库最小数据模型应分成 `mnote_knowledge_bases` 与 `mnote_knowledge_sources` 两层:
|
||||
|
||||
```text
|
||||
mnote_knowledge_bases
|
||||
id
|
||||
user_id
|
||||
workspace_id
|
||||
root_uri
|
||||
name
|
||||
description
|
||||
provider = weknora
|
||||
provider_kb_id
|
||||
default_tool_enabled
|
||||
metadata_json
|
||||
created_at / updated_at / archived_at
|
||||
```
|
||||
|
||||
`mnote_knowledge_bases` 是用户可见的知识库集合;`mnote_knowledge_sources` 是 source 到 provider knowledge/chunk 的映射。删除 source 只删除索引和映射,不删除本地原文件;删除 KB 默认只归档 MNote KB 与删除/禁用对应 WeKnora provider KB,不能批量删除 MNote 本地文件。
|
||||
|
||||
### 5.5 Source Registry provider-neutral 化
|
||||
|
||||
旧 LightRAG 字段应迁移为:
|
||||
旧 LightRAG 字段应迁移为 provider-neutral registry;当前 JSON 文件 `lightrag-source-registry.json` 中的 `lightRagDocId/lightRagStatus/lightRagFilePath/symlinkPath` 等字段必须兼容读取并迁移或双写一段时间。目标结构为:
|
||||
|
||||
```text
|
||||
mnote_knowledge_sources
|
||||
@@ -383,7 +415,7 @@ mnote_knowledge_sources
|
||||
- OpenHub 第一阶段只借用多用户 AI 能力和 AI 页面产品参考,不使用 OpenHub FileManager 或完整前端。
|
||||
- WeKnora 只暴露 MCP/CLI/API 知识工具,返回 knowledge/chunk/reference,不参与页面打开。
|
||||
|
||||
因此这里不需要做 OpenHub FileManager、WeKnora Web UI 或 provider path 的打开融合;只需要保证 MNote Page AI 回复中的 changed file、diff、citation、tool result 能映射回 MNote 页面。
|
||||
因此这里不需要做 OpenHub FileManager、WeKnora 全量 Web UI 或 provider path 的打开融合;只需要保证 MNote Page AI 回复中的 changed file、diff、citation、tool result 能映射回 MNote 页面。注意:这里的“不使用 WeKnora Web UI”只针对 Page AI 对话壳、引用打开链路和文件打开链路;知识库管理页面仍按 5.4 复用 WeKnora KB 页面体验,但必须运行在 MNote shell 与 MNote 权限/source registry 后面。
|
||||
|
||||
### 6.2 决策
|
||||
|
||||
@@ -394,7 +426,7 @@ mnote_knowledge_sources
|
||||
- WeKnora citation / MCP/CLI tool result 点击:`/api/knowledge-rag/open-reference` 返回 MNote locator,再由 MNote 前端打开。
|
||||
- OpenHub session 中出现的 path/diff/tool reference 只作为数据来源,渲染和点击由 MNote Page AI 处理。
|
||||
- 不接 OpenHub FileManager,不使用 OpenHub `/api/files` 作为页面读写入口。
|
||||
- 不嵌 WeKnora Web UI,不让 WeKnora 决定打开哪个 MNote 页面。
|
||||
- 不在 Page AI / 引用打开链路嵌入 WeKnora 全量 Web UI,不让 WeKnora 决定打开哪个 MNote 页面。
|
||||
|
||||
### 6.3 MNote Page AI open reference payload
|
||||
|
||||
@@ -428,15 +460,16 @@ mnote_knowledge_sources
|
||||
```text
|
||||
MNote Page AI host
|
||||
-> MNote 校验登录态、workspace、rootUri、allowed roots
|
||||
-> 注入/映射 OpenHub user workspace + session/skill/MCP scope
|
||||
-> 注入/映射 OpenHub user workspace + session/skill/tool scope
|
||||
-> OpenHub AI UI
|
||||
-> OpenHub FastAPI + Redis/session/cache
|
||||
-> OpenHub FastAPI + SQLite session/message + Redis cache
|
||||
-> OpenHub opencode client
|
||||
-> opencode serve /global/event /session/{id}/prompt_async /diff
|
||||
```
|
||||
|
||||
- **OpenHub FastAPI 继续运行**:负责 AI session/message、skill/MCP、tool permission、opencode client、event stream、diff/changed files 等 OpenHub 原生能力。
|
||||
- **Redis 跟随 OpenHub stack**:如果 OpenHub 用 Redis 做 cache/queue/session 辅助,就由 OpenHub stack 管理;MNote 不把 Redis 当自己的消息、权限、文件或知识库真相。
|
||||
- **OpenHub FastAPI 继续运行**:负责 AI session/message、skill、tool permission、opencode client、event stream、diff/changed files 等 OpenHub 原生能力。
|
||||
- **不要把待补能力写成已存在能力**:OpenHub 源码已确认有 session/message、skill、tool/model permission、SmartEntity、Git snapshot、FileManager、KnowledgeManager 和 opencode event/diff 链路;未确认独立 MCP 管理 API/UI。MCP 第一阶段应由 MNote 注册 WeKnora CLI/MCP/API tool 到 OpenHub/opencode,而不是假设 OpenHub 已有完整 MCP 管理面。
|
||||
- **Redis 跟随 OpenHub stack**:OpenHub Redis 主要用于 token/rate-limit/cache/临时态;OpenHub session/message 真相仍在 SQLite。MNote 不把 Redis 当自己的消息、权限、文件或知识库真相。
|
||||
- **MNote 不重写 planner/client**:MNote 不替 OpenHub 判断何时调用 WeKnora,也不重写 opencode prompt/event/diff 流程。
|
||||
- **MNote 只裁剪不用入口**:OpenHub Login、FileManager、KnowledgeManager、Admin 等页面不暴露;必要时在 proxy 层 404、隐藏菜单或跳转到 MNote 对应页面。
|
||||
|
||||
@@ -445,10 +478,10 @@ MNote Page AI host
|
||||
OpenHub FastAPI 在本设计中保留以下作用:
|
||||
|
||||
- 管理 OpenHub conversation/session/message/history。
|
||||
- 管理 skill、agent/MCP、tool/model permission。
|
||||
- 管理 skill、agent/SmartEntity、tool/model permission。
|
||||
- 调用 `opencode serve`,包括创建 session、发送 prompt、监听 `/global/event`、读取 diff。
|
||||
- 向 OpenHub AI 前端提供消息流、tool card、diff、changed files、history 等 API。
|
||||
- 读取由 MNote 注入的 workspace directory、user scope、allowed roots、WeKnora MCP/CLI/skill 配置。
|
||||
- 读取由 MNote 注入的 workspace directory、user scope、allowed roots、WeKnora CLI/MCP/API tool 配置。
|
||||
|
||||
OpenHub FastAPI 不保留以下作用:
|
||||
|
||||
@@ -460,10 +493,11 @@ OpenHub FastAPI 不保留以下作用:
|
||||
|
||||
### 7.3 Redis 安排
|
||||
|
||||
- OpenHub 需要的 Redis/cache/queue 由 OpenHub deployment 管理,随 OpenHub FastAPI 启停和 health check。
|
||||
- WeKnora 如果有内部 Redis/队列依赖,由 WeKnora stack 管理,MNote 只检查 WeKnora health。
|
||||
- OpenHub 需要的 Redis/cache/临时态由 OpenHub deployment 管理,随 OpenHub FastAPI 启停和 health check;OpenHub `REDIS_HOST/REDIS_PORT/REDIS_DB` 应独立配置,避免误连 WeKnora Redis DB。
|
||||
- WeKnora 有自己的 Redis/Asynq/Langfuse 相关依赖,由 WeKnora stack 管理;MNote 只检查 WeKnora health、必要 API 与 `weknora mcp serve`/CLI profile 是否可用。
|
||||
- MNote control-plane 不依赖 Redis 保存登录授权、workspace grants、source registry、open-reference 或文件真相。
|
||||
- Redis 中只允许放可重建状态:缓存、队列、临时流状态;不能成为用户权限、文件内容、知识库 source registry 或 Page AI 消息的唯一持久真相。
|
||||
- 本机开发阶段允许共用一个 Redis 进程,但必须使用独立 DB/前缀:OpenHub、WeKnora、Langfuse/worker queue 不得混用 keyspace;`dev-hot` health 输出应显示各自 Redis 连接目标。
|
||||
|
||||
### 7.4 Page AI context
|
||||
|
||||
@@ -475,7 +509,7 @@ MNote 注入给 OpenHub / opencode 的 context:
|
||||
- 当前页面标题、真实 Markdown path、resource id
|
||||
- selection 摘要
|
||||
- allowed roots / write constraints
|
||||
- OpenHub session scope / skill scope / MCP scope / tool permission scope
|
||||
- OpenHub session scope / skill scope / tool permission scope
|
||||
- WeKnora tool scope:允许查询的 kb ids/source ids/citation policy
|
||||
- MNote 文件打开/刷新 bridge usage
|
||||
```
|
||||
@@ -489,8 +523,8 @@ MNote 注入给 OpenHub / opencode 的 context:
|
||||
MNote sidebar host 的职责应尽量薄:承载 OpenHub AI 面板、注入 MNote context、处理 MNote 回跳,不重新设计一套顶部/侧边栏产品结构。
|
||||
|
||||
- 顶部:第一阶段可以不要 MNote 自定义顶部;如需状态,只做极简 host 状态条或错误提示,避免覆盖 OpenHub AI 面板原有布局。
|
||||
- 主体:OpenHub AI 面板,由 OpenHub 前端/后端处理消息、tool card、diff、history、agent/MCP/skill 状态。
|
||||
- 侧边:优先保留 OpenHub AI 相关侧边能力,包括历史 session、MCP、skill、agent、tool/model permission 等设置。
|
||||
- 主体:OpenHub AI 面板,由 OpenHub 前端/后端处理消息、tool card、diff、history、agent/skill/tool 状态。
|
||||
- 侧边:优先保留 OpenHub AI 相关侧边能力,包括历史 session、skill、agent/SmartEntity、tool/model permission 等设置;MCP 若当前 OpenHub 前端没有原生管理页,第一阶段显示 WeKnora MCP/CLI tool 连接状态与 MNote scope,而不是重写完整 MCP 管理器。
|
||||
- MNote context:以 context pills / hidden bootstrap / postMessage / proxy header 方式注入当前 page path、rootUri、selection、allowed roots,不强行改 OpenHub UI 主结构。
|
||||
- 回跳:changed file、citation、reference 点击时走 MNote bridge 打开页面。
|
||||
|
||||
@@ -502,8 +536,8 @@ MNote sidebar host 的职责应尽量薄:承载 OpenHub AI 面板、注入 MNo
|
||||
|
||||
- AI chat 主界面。
|
||||
- history / session 抽屉。
|
||||
- MCP 设置与连接状态。
|
||||
- skill / agent 设置。
|
||||
- WeKnora CLI/MCP tool 的连接状态;若 OpenHub 无原生 MCP 设置 UI,则通过 MNote host 或 OpenHub 最小扩展显示,不阻塞 AI 主界面。
|
||||
- tool/model permission UI。
|
||||
- tool card、diff、changed files、运行日志等 AI 运行态 UI。
|
||||
|
||||
@@ -518,12 +552,12 @@ MNote sidebar host 的职责应尽量薄:承载 OpenHub AI 面板、注入 MNo
|
||||
|
||||
### 8.3 MNote 暴露给 OpenHub 的边界能力
|
||||
|
||||
MNote 不替 OpenHub 判断何时调用知识库、何时用 skill/MCP、如何组织 tool call;这些交给 OpenHub/opencode 已有 MCP/skill/agent 规范处理。MNote 只提供最小边界能力:
|
||||
MNote 不替 OpenHub 判断何时调用知识库、何时用 skill/tool/MCP 工具、如何组织 tool call;这些交给 OpenHub/opencode 已有 skill/tool/agent 规范处理,并由 MNote 注册的 WeKnora CLI/MCP/API tool 提供知识能力。MNote 只提供最小边界能力:
|
||||
|
||||
```text
|
||||
- 当前页面 context:page path / title / selection / rootUri / allowed roots
|
||||
- OpenHub scope:user/workspace/rootUri 派生的 session/skill/MCP/tool permission scope
|
||||
- WeKnora MCP/CLI/API 配置:以 skill/MCP/tool 形式注册给 OpenHub/opencode
|
||||
- OpenHub scope:user/workspace/rootUri 派生的 session/skill/tool permission scope
|
||||
- WeKnora CLI/MCP/API 配置:以 skill/tool/MCP 工具形式注册给 OpenHub/opencode
|
||||
- open-reference bridge:把 provider citation/chunk/source 映射成 MNote 页面打开动作
|
||||
- changed-file bridge:把 OpenHub/opencode 返回的 path 映射成 MNote document pane 打开/刷新
|
||||
```
|
||||
@@ -538,7 +572,7 @@ MNote 不替 OpenHub 判断何时调用知识库、何时用 skill/MCP、如何
|
||||
用户输入
|
||||
-> MNote Page AI host 中的 OpenHub AI 面板
|
||||
-> OpenHub 前端调用 OpenHub FastAPI
|
||||
-> OpenHub FastAPI 使用 OpenHub session/message/skill/MCP/tool permission
|
||||
-> OpenHub FastAPI 使用 OpenHub session/message/skill/tool permission 与 MNote 注册的 WeKnora tool scope
|
||||
-> OpenHub FastAPI 调 opencode serve
|
||||
-> opencode 读写 MNote 授权 rootUri 内文件
|
||||
-> OpenHub session/message/tool history 持久化
|
||||
@@ -552,14 +586,14 @@ MNote 不在消息主链里重写 OpenHub planner,也不解析知识需求后
|
||||
|
||||
```text
|
||||
OpenHub/opencode 判断需要知识
|
||||
-> 按 MCP/skill/tool 规范调用已注册的 WeKnora 工具
|
||||
-> 按 skill/tool/MCP 规范调用已注册的 WeKnora 工具
|
||||
-> WeKnora MCP/CLI/API 返回 chunks/references
|
||||
-> OpenHub/opencode 把结果纳入当前 session/tool result
|
||||
-> 用户点击 citation/reference 时
|
||||
-> MNote open-reference bridge 按 source registry / allowed roots 映射并打开对应页面
|
||||
```
|
||||
|
||||
MNote 不负责替 OpenHub 判断 knowledge scope;scope 在注册 WeKnora MCP/skill/tool 时由 MNote 根据当前用户、workspace、allowed roots 预先约束。
|
||||
MNote 不负责替 OpenHub 判断 knowledge scope;scope 在注册 WeKnora CLI/MCP/skill/tool 时由 MNote 根据当前用户、workspace、allowed roots 预先约束。
|
||||
|
||||
### 9.3 知识库生成与展示
|
||||
|
||||
@@ -570,27 +604,38 @@ MNote 知识库展示层上传/添加资料
|
||||
-> WeKnora file/manual/url ingest
|
||||
-> 写 mnote_knowledge_sources registry
|
||||
-> FileTree/Knowledge UI 显示 indexing 状态
|
||||
-> 生成/更新可供 OpenHub/opencode 使用的 WeKnora MCP/skill/tool 配置
|
||||
-> 生成/更新可供 OpenHub/opencode 使用的 WeKnora CLI/MCP/skill/tool 配置
|
||||
```
|
||||
|
||||
OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边界配置好的 WeKnora 工具。知识库生成页面复用 WeKnora KB 页面体验,但 source 选择应以 MNote 本地文件/文件夹集合为入口。
|
||||
|
||||
## 10. 可执行 Checklist
|
||||
|
||||
本节保留主线阶段清单;逐文件、逐接口、逐 smoke 的细化执行项见 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md`。实施时先完成配套清单 P0/P1,再回填本文状态;不要把 P2/P3 优化提前混入 MVP。
|
||||
|
||||
|
||||
### 10.1 设计与旧路径冻结
|
||||
|
||||
- [ ] 在 `7-65` 标注官方 opencode iframe 只保留为 fallback,不再作为 Page AI 产品主线。
|
||||
- [ ] 在 `7-66` 标注自研 native UI 只保留为 fallback,不再继续扩自研聊天框。
|
||||
- [ ] 在 `7-67` 标注已被本文覆盖:OpenHub 路线从“参考/重写”改为“嵌入 AI 面板 + 保留 FastAPI/Redis/opencode client”。
|
||||
- [ ] 将本文保留在 `design/07-ai/process/`,作为当前 Page AI 嵌入式集成主设计。
|
||||
- [ ] 在相关 design / bugs / testing 文档中统一术语:WeKnora 是唯一知识库底座,MNote 是知识库展示和文件真相层,OpenHub KnowledgeManager 不作为知识库页。
|
||||
- [ ] 搜索并标记仍把 LightRAG 描述为默认知识库 provider 的文案,改成 legacy/fallback。
|
||||
- [x] 在 `7-65` 标注官方 opencode iframe 只保留为 fallback,不再作为 Page AI 产品主线。
|
||||
- [x] 在 `7-66` 标注自研 native UI 只保留为 fallback,不再继续扩自研聊天框。
|
||||
- [x] 在 `7-67` 标注已被本文覆盖:OpenHub 路线从“参考/重写”改为“嵌入 AI 面板 + 保留 FastAPI/Redis/opencode client”。
|
||||
- [x] 将本文保留在 `design/07-ai/process/`,作为当前 Page AI 嵌入式集成主设计。
|
||||
- [x] 在本轮授权写入范围内统一术语:WeKnora 是唯一知识库底座,MNote 是知识库展示和文件真相层,OpenHub KnowledgeManager 不作为知识库页;bugs/testing 文档未在本任务授权写入范围内修改。
|
||||
- [x] 在本轮授权写入范围内搜索并标记仍把 LightRAG 描述为默认知识库 provider 的文案,改成 legacy/fallback。
|
||||
|
||||
#### 10.1 证据
|
||||
|
||||
- `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md`:已冻结为官方 opencode WebUI iframe fallback;上下文口径从 LightRAG 引用改为 WeKnora 引用,旧 LightRAG 仅 legacy/fallback。
|
||||
- `design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md`:已冻结为 native UI fallback / debug receipt,不再扩写自研聊天框。
|
||||
- `design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md`:已标注被本文覆盖,路线改为嵌入 OpenHub AI 面板并保留 OpenHub FastAPI/Redis/opencode client。
|
||||
- `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md`:保留在 `process/`,作为当前 Page AI 嵌入式集成主设计;10.2-10.9 后续执行细化到 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md`。
|
||||
|
||||
### 10.2 OpenHub 服务栈接入
|
||||
|
||||
- [ ] 确认 OpenHub 本机源码路径、启动命令、依赖文件和默认端口。
|
||||
- [ ] 确认 OpenHub FastAPI 是否真实依赖 Redis;记录 Redis host/port/env 和启动顺序。
|
||||
- [ ] 确认 OpenHub FastAPI Redis 的真实用途:token/rate-limit/cache/临时态;记录 Redis host/port/db/env 和启动顺序,避免写成消息真相或队列主链。
|
||||
- [ ] 确认 OpenHub FastAPI 调 opencode 的配置项:opencode base URL、directory 参数、BasicAuth、模型/provider env。
|
||||
- [ ] 确认 OpenHub 自动 Git snapshot 的触发点,并通过配置/补丁关闭 `stream.py`、`task_executor.py` 中的自动 snapshot 写链。
|
||||
- [ ] 在 `scripts/desktop-hot.js` 或等价 dev-hot 链路中增加 OpenHub FastAPI、Redis、opencode serve 的启动/跳过/health check。
|
||||
- [ ] 增加 OpenHub health endpoint 探针;失败时错误信息区分 FastAPI、Redis、opencode。
|
||||
- [ ] 禁用 OpenHub launcher 的 kill-port 或 destructive workspace 行为,避免影响 MNote dev 进程。
|
||||
@@ -605,7 +650,7 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
- [ ] 在 MNote 后端实现或扩展 OpenHub host/proxy bootstrap endpoint,输出 user/workspace/session/tool scope。
|
||||
- [ ] 禁止前端持有 OpenHub JWT/localStorage 登录真相;OpenHub 用户态由 MNote 后端注入或代理。
|
||||
- [ ] rootUri / workspace 切换时新建或切换 OpenHub session,旧 session 标记 stale/archived。
|
||||
- [ ] 不同 MNote 用户访问同一页面时不得复用同一 OpenHub session、skill scope、MCP scope、tool permission scope。
|
||||
- [ ] 不同 MNote 用户访问同一页面时不得复用同一 OpenHub session、skill scope、tool permission scope 或 WeKnora tool scope。
|
||||
- [ ] 写 control-plane 测试:binding 受 user/workspace/rootUri 隔离,跨用户查询失败。
|
||||
|
||||
### 10.4 OpenHub AI 面板嵌入
|
||||
@@ -613,7 +658,7 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
- [ ] 在 MNote Page AI sidebar host 中选择 iframe 或 reverse proxy 嵌入方式。
|
||||
- [ ] 只暴露 OpenHub AI 页面路由;Login/Admin/Team/Scheduler/SmartEntity 默认不可达。
|
||||
- [ ] OpenHub workspace selector 固定到 MNote 授权 rootUri 派生 workspace。
|
||||
- [ ] 保留 OpenHub AI 主界面、history/session、MCP 设置、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志。
|
||||
- [ ] 保留 OpenHub AI 主界面、history/session、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志;MCP 管理面如源码不存在,不把它作为已完成 OpenHub UI 依赖。
|
||||
- [ ] FileManager 入口若出现在 AI 面板内,跳转 MNote document pane / FileTree 或禁用。
|
||||
- [ ] KnowledgeManager 入口若出现在 AI 面板内,跳转 MNote WeKnora 知识库页或隐藏。
|
||||
- [ ] MNote context 通过 postMessage、proxy header 或 bootstrap JSON 注入 page path、title、selection、rootUri、allowed roots。
|
||||
@@ -634,7 +679,7 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
### 10.6 WeKnora 知识库页面替换
|
||||
|
||||
- [ ] 盘点 WeKnora `KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue` 的依赖。
|
||||
- [ ] 决定复用方式:嵌 WeKnora frontend route、抽组件、或做 MNote adapter 页面复刻 WeKnora 交互。
|
||||
- [ ] 决定复用方式:优先 MNote adapter 页面复刻 WeKnora 交互;可选抽组件;不采用 iframe WeKnora 全量 frontend route 作为产品主线。
|
||||
- [ ] 知识库定义为 MNote 授权文件/文件夹/page resource 集合,不创建新的文件内容真相。
|
||||
- [ ] 建立 source set 模型:kb id、workspace id、rootUri、source path/resource id、provider kb id、provider knowledge id、source hash。
|
||||
- [ ] source 选择 UI 接 MNote FileTree / resource picker,而不是 WeKnora 自己的独立文件真相。
|
||||
@@ -648,10 +693,10 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
- [ ] 抽出 `KnowledgeProvider` 或等价 provider boundary,新增 `weknora` 实现。
|
||||
- [ ] `/api/knowledge-rag/status` 改为检查 WeKnora health、KB 映射、source registry 状态。
|
||||
- [ ] `/api/knowledge-rag/ingest` 改为 WeKnora ingest,并写入 provider KB / knowledge / source hash。
|
||||
- [ ] `/api/knowledge-rag/search` 改为 WeKnora `/api/v1/knowledge-search` 或 KB 级 hybrid-search。
|
||||
- [ ] `/api/knowledge-rag/search` 改为 WeKnora `/api/v1/knowledge-search`;单 KB 调试/CLI/MCP 可走 `/api/v1/knowledge-bases/:id/hybrid-search`。
|
||||
- [ ] `/api/knowledge-rag/query` 第一阶段由 WeKnora search results + citations 组成 query result,不启用 WeKnora chat session。
|
||||
- [ ] `/api/knowledge-rag/section-context` 改为基于 WeKnora chunk + MNote 本地 locator,不读 LightRAG sidecar。
|
||||
- [ ] `open-reference` 从 WeKnora `knowledge_id/chunk_id/knowledge_base_id` 回查 MNote registry,再生成 MNote locator。
|
||||
- [ ] `open-reference` 从 WeKnora `knowledge_id/chunk_id/knowledge_base_id` 回查 MNote registry,再生成 MNote locator;保留 `content/matched_content/match_type/metadata/chunk_metadata/image_info/parent_chunk_id/sub_chunk_id` 供 citation 与诊断。
|
||||
- [ ] WeKnora RRF score 不沿用 LightRAG 阈值;UI 只显示排序分或弱化分值解释。
|
||||
- [ ] 保留旧 LightRAG provider 为 legacy fallback,但默认隐藏且不作为 smoke 基线。
|
||||
- [ ] 更新现有 knowledge-rag smoke,把 provider 断言从 `lightrag` 改为 `weknora`。
|
||||
@@ -659,7 +704,7 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
|
||||
### 10.8 WeKnora MCP/CLI Tool Bridge
|
||||
|
||||
- [ ] 选定第一阶段 MCP surface:Go CLI `weknora mcp serve` 或 Python `mcp-server`,记录读写能力差异。
|
||||
- [ ] 第一阶段 MCP surface 选定 Go CLI `weknora mcp serve` 或 MNote facade 包装后的等价只读工具面;Python `mcp-server` 只作为后续写能力参考,不默认暴露给 OpenHub/opencode。
|
||||
- [ ] 默认暴露只读工具:`mnote.weknora.search`、`mnote.weknora.list_sources`、`mnote.weknora.get_source_status`、`mnote.weknora.open_reference`。
|
||||
- [ ] 写工具 manifest / skill,使 OpenHub/opencode 能在当前 session scope 内调用 WeKnora。
|
||||
- [ ] tool 调用前注入 KB/source allowlist,不让 OpenHub/opencode 查询未授权 source。
|
||||
@@ -672,7 +717,7 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
- [ ] `npm run dev:hot` 启动或检查 MNote、OpenHub FastAPI、Redis、opencode、WeKnora。
|
||||
- [ ] 登录 `mnote.e2e@example.com`,确认没有 OpenHub/WeKnora 登录跳转。
|
||||
- [ ] 打开真实 Markdown 页面并打开 Page AI。
|
||||
- [ ] Page AI 嵌入 OpenHub AI 面板,history/session/MCP/skill/agent/tool permission 入口仍可用。
|
||||
- [ ] Page AI 嵌入 OpenHub AI 面板,history/session/skill/agent/tool permission 入口仍可用;WeKnora MCP/CLI tool 状态可见。
|
||||
- [ ] OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口被隐藏、404 或跳转 MNote。
|
||||
- [ ] 发送消息后 OpenHub session/message/history 持久化,刷新浏览器可恢复。
|
||||
- [ ] 让 AI 修改当前 rootUri 内 Markdown,MNote document pane 可刷新并显示变更。
|
||||
@@ -690,8 +735,8 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
| 知识库 UI | OpenHub KnowledgeManager 暂不使用 | WeKnora KB list/detail/upload/status 体验可复用 | MNote shell + WeKnora KB UI adapter | 选 WeKnora 知识库页面体验,嵌入 MNote 外壳 |
|
||||
| 知识库底座 | OpenHub 自带知识库暂不用 | 强 RAG/Wiki/Graph | MNote registry + WeKnora | 选 WeKnora |
|
||||
| 文件打开 | workspace path | knowledge filename | MNote resource/open-reference | 选 MNote |
|
||||
| 权限 | 模型/工具/skill/MCP | tenant RBAC | MNote scope + OpenHub/opencode 权限规范 + provider 防线 | 组合,以 MNote scope 为边界 |
|
||||
| Agent 编辑 | opencode | WeKnora agent-chat 暂弃用 | opencode 编辑 + WeKnora MCP/CLI/API 知识工具 | 组合 |
|
||||
| 权限 | 模型/工具/skill;MCP 由工具配置承接 | tenant RBAC | MNote scope + OpenHub/opencode 权限规范 + provider 防线 | 组合,以 MNote scope 为边界 |
|
||||
| Agent 编辑 | opencode | WeKnora agent-chat 暂弃用 | opencode 编辑 + WeKnora CLI/MCP/API 知识工具 | 组合 |
|
||||
| UI 成本 | 嵌入 AI 面板,裁剪非 AI 入口 | 不适合 Page AI 编辑侧栏 | MNote host + OpenHub AI 面板 | 选 OpenHub AI 面板嵌入 |
|
||||
| 运维 | OpenHub FastAPI + Redis + opencode 运行 | WeKnora 服务 | MNote 启动/检查 OpenHub/opencode/Redis/WeKnora,提供 binding/tool 配置 | 选 OpenHub 栈运行 + MNote 边界控制 |
|
||||
|
||||
@@ -699,7 +744,7 @@ OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边
|
||||
|
||||
### 12.1 风险:三套权限漂移
|
||||
|
||||
防线:MNote 是浏览器入口和授权边界;OpenHub/opencode 只能拿到 MNote 派生 scope、allowed roots、tool/MCP 配置,不直接获得未过滤 workspace/rootUri。
|
||||
防线:MNote 是浏览器入口和授权边界;OpenHub/opencode 只能拿到 MNote 派生 scope、allowed roots、tool/CLI/MCP 配置,不直接获得未过滤 workspace/rootUri。
|
||||
|
||||
补充:WeKnora RBAC 只能作为 provider 防线,不作为 MNote 授权来源;RBAC 关闭、API key 复用或共享空间变化时,MNote 过滤结果仍必须保持一致。
|
||||
|
||||
@@ -736,16 +781,27 @@ MVP 通过必须同时满足:
|
||||
- 同一 MNote 用户跨浏览器可恢复绑定到当前页面/rootUri 的 OpenHub session。
|
||||
- opencode 在当前 rootUri 下能真实读取/修改 Markdown。
|
||||
- changed file chips 与 DiffViewer path 能用 MNote 打开。
|
||||
- MNote 知识库页面已由 `MNote shell + WeKnora KB adapter` 替换简陋页:可创建/列出/查看 KB,可从 MNote FileTree/resource picker 添加本地文件或文件夹,可显示 WeKnora processing/indexed/failed 状态。
|
||||
- `/api/knowledge-rag/status/search/query/section-context/open-reference/delete-source` 默认 provider 为 `weknora`;旧 LightRAG 只作为 `lightrag_legacy` 隐藏 fallback,不再作为默认 smoke 基线。
|
||||
- WeKnora search/MCP/CLI 工具返回真实引用,citation 经 registry 映射后可回跳 MNote 文件;失败时明确定位降级,且不能把 `knowledge_filename` 伪装成本地路径。
|
||||
- OpenHub/opencode 通过 MNote 注册的 WeKnora tool scope 调用知识库;MNote 不替 OpenHub 做 planner 判断,只限制 KB/source allowlist 与 open-reference。
|
||||
- `npm run dev:hot` 能拉起并检查必要 runtime;失败时错误页给出 OpenHub FastAPI、Redis、opencode、WeKnora 或 OpenHub session binding 哪个不可达。
|
||||
- MNote Page AI 路径不触发 OpenHub 登录跳转;OpenHub FileManager/KnowledgeManager/Admin 等非 AI 入口被隐藏、404 或跳转 MNote;消息真相保留在受 MNote scope 隔离的 OpenHub session 中,不触发 Git snapshot/restore 自动写链。
|
||||
|
||||
## 14. 本轮源码依据
|
||||
|
||||
- OpenHub 源码:`/tmp/mnote-openhub-research/OpenHub`
|
||||
- OpenHub 源码:`/tmp/mnote-openhub-research/OpenHub`(当前无 `.codegraph/`,本轮以 targeted `find/rg/sed` 复核)
|
||||
- WeKnora 本机部署:`/mnt/Data1T/Mnote_data/weknora/WeKnora`
|
||||
- MNote Page AI runtime:`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
|
||||
- MNote document pane bridge:`rust/crates/mnote-web/browser/document-editor-adapter-runtime.js`
|
||||
- MNote opencode route:`rust/crates/mnote-web/src/routes/page_ai_opencode.rs`
|
||||
- MNote knowledge route:`rust/crates/mnote-web/src/routes/knowledge_rag.rs`
|
||||
- MNote auth/session route:`rust/crates/mnote-web/src/routes/session.rs`、`rust/crates/mnote-web/src/routes/gateway.rs`
|
||||
|
||||
- OpenHub FastAPI 入口:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/main.py`
|
||||
- OpenHub opencode client / launcher:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/opencode_client.py`、`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/opencode_launcher.py`
|
||||
- OpenHub streaming / Git snapshot 触发:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/stream.py`、`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/task_executor.py`
|
||||
- OpenHub session/auth/files/knowledge/admin:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/session.py`、`auth.py`、`files.py`、`knowledge.py`、`admin.py`
|
||||
- WeKnora SearchResult / hybrid-search client:`/mnt/Data1T/Mnote_data/weknora/WeKnora/client/knowledgebase.go`
|
||||
- WeKnora MCP CLI:`/mnt/Data1T/Mnote_data/weknora/WeKnora/cli/internal/mcp/tools.go`、`/mnt/Data1T/Mnote_data/weknora/WeKnora/cli/cmd/mcp/serve.go`
|
||||
- WeKnora KB UI:`/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/`
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# 7-68 WeKnora 知识库页面 UI 参考
|
||||
|
||||
状态:reference
|
||||
范围:只记录 WeKnora 知识库页面可嫁接的页面结构、状态与 MNote 字段映射;不修改 MNote runtime,不修改 WeKnora 源码。
|
||||
|
||||
## 结论
|
||||
|
||||
7-68 的知识库页不应继续保留 MNote 原来的简陋“知识库设置面板”形态。更合适的嫁接目标是复用 WeKnora 的“知识库列表 + 知识库详情文档管理页”体验:列表页提供知识库卡片、分组、上传进度和能力徽标;详情页提供面包屑、文档/ Wiki / 图谱页签、标签侧栏、文档筛选栏、网格/列表切换、上传入口、解析状态和批量管理。
|
||||
|
||||
MNote 应保留本地 source registry、allowed roots、workspace path、provider 映射和打开引用能力;WeKnora 页面结构只作为 UI / 交互参考,不直接接管 MNote 的事实源、鉴权、文件写入或索引生命周期。
|
||||
|
||||
## WeKnora 源文件
|
||||
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBaseList.vue`
|
||||
- 知识库列表页主入口。
|
||||
- 核心结构:`kb-list-container`、`ListSpaceSidebar`、标题区、未初始化提示、上传进度面板、知识库卡片网格、分组标题、收藏 / 置顶 / 设置 / 删除菜单、能力徽标。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBase.vue`
|
||||
- 单个知识库详情页主入口。
|
||||
- 核心结构:`knowledge-layout`、面包屑 + `KBSwitcherDropdown`、`KBInfoPopover`、设置按钮、Documents / Wiki / Graph 页签、标签侧栏、文档筛选栏、文档卡片网格、列表视图、`DocContent` 抽屉。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/DocumentListView.vue`
|
||||
- 文档列表视图。
|
||||
- 核心结构:吸顶表头、复选框、多列 row、来源、大小、解析状态、更新时间、行菜单。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/KbUploadSourceDropdown.vue`
|
||||
- 添加文档入口。
|
||||
- 支持上传文件、上传文件夹、导入 URL,可选手动创建。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/DocumentBatchBar.vue`
|
||||
- 批量选择后的浮动操作条。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/components/knowledge-processing-timeline.vue`
|
||||
- 解析 / 后处理 trace 时间线。
|
||||
- 显示 docreader、chunking、embedding、multimodal、postprocess 等阶段,以及 live polling、失败、取消和耗时。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/components/empty-knowledge.vue`
|
||||
- 空知识库占位。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/api/knowledge-base/index.ts`
|
||||
- API 字段参考:`listKnowledgeBases`、`getKnowledgeBaseById`、`uploadKnowledgeFile`、`createKnowledgeFromURL`、`listKnowledgeFiles`、`reparseKnowledge`、`cancelKnowledgeParse`、`batchDeleteKnowledge`、`getKnowledgeSpans`。
|
||||
- `/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/i18n/locales/zh-CN.ts`
|
||||
- 中文文案参考:`knowledgeBase`、`knowledgeList`、`knowledgeEditor`。
|
||||
|
||||
## 可嫁接页面结构
|
||||
|
||||
### 1. 知识库列表
|
||||
|
||||
最小结构:
|
||||
|
||||
- 左侧范围筛选:全部 / 我的 / 收藏 / 最近 / 空间,MNote 首版可降级为全部 + 当前 workspace。
|
||||
- 顶部标题:`知识库`,副标题采用 WeKnora 语义“管理和组织您的知识库,支持文档型和问答型知识库”。
|
||||
- 新建按钮:图标按钮或小按钮,接 MNote 创建 KB / 绑定本地 folder 的流程。
|
||||
- 全局状态提示:
|
||||
- 未初始化 / provider 未连接提示。
|
||||
- 上传或索引中的 progress panel。
|
||||
- 卡片网格:
|
||||
- 标题、描述。
|
||||
- 文档数量 / FAQ 数量 / processing loading。
|
||||
- 能力徽标:知识图谱、多模态、问题生成、共享。
|
||||
- 卡片菜单:置顶、设置、删除。
|
||||
- hover 显示边框与更多按钮。
|
||||
|
||||
MNote 首版不需要完整照搬 WeKnora 的组织共享分组,但应保留分组能力的视觉槽位,避免后续 workspace / shared KB 接入时重做页面。
|
||||
|
||||
### 2. 知识库详情
|
||||
|
||||
最小结构:
|
||||
|
||||
- 面包屑:`知识库 / 当前知识库 / 文档`。
|
||||
- 当前 KB 下拉切换:参考 `KBSwitcherDropdown`,MNote 可映射为当前 workspace 下 KB registry。
|
||||
- 右侧动作:信息 popover、设置按钮。
|
||||
- 文档页签:
|
||||
- `文档` 为默认主视图。
|
||||
- 若 provider 暴露 wiki / graph 能力,再显示 `Wiki`、`图谱` 页签。
|
||||
- 左侧标签侧栏:
|
||||
- `文档分类` 标题、数量、搜索、标签列表、新建 / 重命名 / 删除。
|
||||
- MNote 首版可先映射为 source kind / folder / tag,不能伪造 WeKnora 后端 tag 写入。
|
||||
- 主内容筛选栏:
|
||||
- 搜索文档。
|
||||
- 文件类型筛选。
|
||||
- 解析状态筛选。
|
||||
- 来源筛选。
|
||||
- 更新时间范围。
|
||||
- 网格 / 列表切换。
|
||||
- 添加文档入口。
|
||||
- 文档区:
|
||||
- skeleton loading。
|
||||
- grid 卡片作为默认视图。
|
||||
- list 作为密集视图。
|
||||
- 空状态。
|
||||
- 批量选择浮动条。
|
||||
- 文档详情:
|
||||
- 点击文档打开 `DocContent` 风格抽屉,展示全文、分块、原文件 / 引用。
|
||||
|
||||
### 3. 上传与导入
|
||||
|
||||
参考 `KbUploadSourceDropdown.vue` 的四类入口:
|
||||
|
||||
- 上传文档。
|
||||
- 上传文件夹。
|
||||
- 导入网页 URL。
|
||||
- 手动创建。
|
||||
|
||||
MNote 映射时应改成:
|
||||
|
||||
- 上传文档 / 文件夹:落到 MNote local-first source registry,记录 local path / providerKnowledgeBaseId / providerKnowledgeId,再交 WeKnora provider ingest。
|
||||
- 导入 URL:可作为 provider source,不对应本地文件。
|
||||
- 手动创建:如果没有明确的 MNote 文档创建语义,首版不启用,避免产生第二套正文事实源。
|
||||
|
||||
### 4. 状态与交互
|
||||
|
||||
WeKnora 的文档状态结构可直接作为 MNote UI 状态模型参考:
|
||||
|
||||
- `pending` / `processing`:显示 loading、解析中,可打开 trace。
|
||||
- `finalizing`:主解析完成但 summary / question / graph 等后处理仍在跑。
|
||||
- `failed`:红色失败状态,可重建 / 查看 trace。
|
||||
- `cancelled`:黄色取消状态。
|
||||
- `draft`:草稿。
|
||||
- `completed`:绿色完成。
|
||||
- `completed + summary pending/processing`:显示“生成摘要中”。
|
||||
|
||||
重要交互:
|
||||
|
||||
- 卡片和列表都能打开文档详情。
|
||||
- 更多菜单提供重建、取消解析、移动、批量管理、删除。
|
||||
- 解析中 / 失败状态应能打开 trace。
|
||||
- 网格 / 列表视图选择可持久化到 localStorage。
|
||||
- 批量选择不能与普通打开文档冲突。
|
||||
|
||||
## MNote 字段映射
|
||||
|
||||
| WeKnora UI 字段 | MNote 映射建议 |
|
||||
| --- | --- |
|
||||
| `kb.id` | MNote `knowledge_base_id` / registry entry id |
|
||||
| `kb.name` | 知识库显示名,默认可来自 folder name |
|
||||
| `kb.description` | registry description / provider description |
|
||||
| `kb.type` | MNote 首版固定 `document`,FAQ 暂不作为主路径 |
|
||||
| `kb.knowledge_count` | provider 文档数 + registry source count |
|
||||
| `kb.chunk_count` | provider chunk count,可从 WeKnora status / search metadata 显示 |
|
||||
| `kb.isProcessing` | registry processing count 或 provider parse in-flight count |
|
||||
| `extract_config.enabled` | WeKnora graph / MNote graph capability |
|
||||
| `vlm_config.enabled` | 多模态 / 图片解析能力 |
|
||||
| `question_generation_config.enabled` | 问题生成能力 |
|
||||
| `storage_provider_config.provider` | provider / vector store / file store 显示,不作为 MNote 文件事实源 |
|
||||
| `knowledge.id` | `providerKnowledgeId` |
|
||||
| `knowledge.file_name` | source display name / local file basename |
|
||||
| `knowledge.file_type` | 文件扩展名 / source kind |
|
||||
| `knowledge.file_size` | source file size,URL/manual 可为空 |
|
||||
| `knowledge.tag_id` | MNote tag / folder / source group 映射,首版可只读 |
|
||||
| `knowledge.parse_status` | provider parse status |
|
||||
| `knowledge.summary_status` | provider postprocess status |
|
||||
| `knowledge.channel` | `web` / `api` / `url` / `manual` / local upload 映射 |
|
||||
| `knowledge.source` | local path / URL / provider source |
|
||||
| `knowledge.updated_at` | registry updated_at 或 provider updated_at |
|
||||
|
||||
## 不能直接接管的边界
|
||||
|
||||
- 不直接把 WeKnora Vue 组件复制进 MNote runtime;MNote 当前主壳是 Rust SSR / browser runtime,不是 WeKnora Vue 应用。
|
||||
- 不让 WeKnora 成为 MNote 的文件事实源;本地 `.md`、source registry、workspace path、allowed roots 仍归 MNote。
|
||||
- 不把 WeKnora 的 tenant / organization / share 语义直接套到 MNote;MNote 的 auth、membership、share grants 由 Rust SQLite control-plane 管。
|
||||
- 不把 WeKnora 的 tag 删除语义直接映射到 MNote folder 删除;WeKnora tag delete 可能级联删除文档,MNote 需要独立确认。
|
||||
- 不直接启用手动创建文档,除非 MNote 明确将其映射到本地文件或页面创建命令。
|
||||
- 不直接复用 WeKnora 的轮询策略作为 MNote 主链;MNote 可展示 provider status,但刷新应优先走 MNote watcher / event / command result。
|
||||
- 不直接暴露 WeKnora 的删除 KB / 删除文档为本地文件删除;首版删除应只删除 provider index 与 registry 映射,是否删除本地文件必须单独确认。
|
||||
- 不把 WeKnora `Wiki` / `Graph` 页签默认显示为可用;只有 provider capability 明确可用时展示。
|
||||
|
||||
## MNote 最小呈现建议
|
||||
|
||||
首版替换原知识库设置面板时,建议至少实现以下可见结构:
|
||||
|
||||
1. 知识库列表页:标题、副标题、新建/绑定入口、KB 卡片网格、文档数、processing 状态、provider 能力徽标、设置入口。
|
||||
2. 知识库详情页:面包屑、KB 切换、文档分类侧栏、搜索/筛选栏、添加文档下拉、网格/列表切换。
|
||||
3. 文档项:文件名、描述/摘要、标签或来源、文件类型、更新时间、解析状态、打开引用。
|
||||
4. 状态层:pending / processing / finalizing / completed / failed / cancelled / draft 至少要有不同视觉表达。
|
||||
5. 安全边界:删除、移动、手动创建、共享、Wiki / Graph、tag 写入可以先隐藏或只读,避免 UI 暗示已具备不可逆能力。
|
||||
|
||||
Generated
+17
@@ -1759,6 +1759,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2367,6 +2377,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3255,6 +3266,12 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -16,7 +16,7 @@ hyper = "1"
|
||||
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "stream"] }
|
||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -199,6 +199,12 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiOpencodeEventSource: null,
|
||||
pageAiOpencodeEventSessionId: '',
|
||||
pageAiOpencodeEventTimer: 0,
|
||||
pageAiOpenHubStatus: null,
|
||||
pageAiOpenHubStatusError: '',
|
||||
pageAiOpenHubBootstrap: null,
|
||||
pageAiOpenHubIframeUrl: '',
|
||||
pageAiOpenHubScopeSummary: '',
|
||||
pageAiOpenHubFallbackReason: '',
|
||||
pageAiSessionSearchQuery: '',
|
||||
pageAiSessionSearchResults: [],
|
||||
pageAiSessionSearchTimer: 0,
|
||||
@@ -886,6 +892,10 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiOpenHubHostEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiOpencodeStatusLabel(status) {
|
||||
var node = status && typeof status === 'object' ? status : {};
|
||||
var state = String(node.status || node.state || node.runtimeStatus || '').trim();
|
||||
@@ -949,6 +959,44 @@ export function createSidebarPageAiRuntime(context) {
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiNormalizeRootRelativePath(path) {
|
||||
return String(path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function pageAiRootPathFromFileUri(rootUri) {
|
||||
var root = String(rootUri || '').trim();
|
||||
if (!root.startsWith('file://')) return '';
|
||||
try {
|
||||
return decodeURIComponent(new URL(root).pathname || '').replace(/\/+$/, '');
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiRootRelativePathFromChangedPath(path, rootUri) {
|
||||
var value = String(path || '').trim();
|
||||
if (!value) return '';
|
||||
try {
|
||||
var parsed = new URL(value, window.location.origin);
|
||||
if (parsed.pathname === '/api/local-folder/files/open') {
|
||||
return pageAiNormalizeRootRelativePath(parsed.searchParams.get('path') || '');
|
||||
}
|
||||
if (parsed.protocol === 'file:') value = decodeURIComponent(parsed.pathname || '');
|
||||
} catch (_error) {}
|
||||
value = value.replace(/^file:\/\//, '');
|
||||
var rootPath = pageAiRootPathFromFileUri(rootUri || currentRootUri());
|
||||
if (rootPath && value.startsWith(rootPath + '/')) {
|
||||
value = value.slice(rootPath.length + 1);
|
||||
}
|
||||
return pageAiNormalizeRootRelativePath(value);
|
||||
}
|
||||
|
||||
function pageAiDocumentIdFromRootRelativePath(relativePath) {
|
||||
var normalized = pageAiNormalizeRootRelativePath(relativePath);
|
||||
if (!normalized || !/\.(md|markdown)$/i.test(normalized)) return '';
|
||||
return localMarkdownDocumentIdFromPageAiRelativePath(normalized);
|
||||
}
|
||||
|
||||
function pageAiOpencodeCurrentPageLabel() {
|
||||
var aggregate = currentPageAggregate();
|
||||
var meta = aggregate && aggregate.meta && typeof aggregate.meta === 'object' ? aggregate.meta : {};
|
||||
@@ -983,6 +1031,95 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiMNoteDocumentPathSegment(documentId) {
|
||||
return encodeURIComponent(String(documentId || '').trim()).replace(/%3A/g, ':').replace(/%7E/g, '~');
|
||||
}
|
||||
|
||||
function pageAiParentRelativePath(relativePath) {
|
||||
var normalized = pageAiNormalizeRootRelativePath(relativePath);
|
||||
if (!normalized || normalized.indexOf('/') === -1) return '';
|
||||
return normalized.split('/').slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
function pageAiFileUriForRootRelativePath(rootUri, relativePath) {
|
||||
var root = String(rootUri || '').trim().replace(/\/+$/, '');
|
||||
var relative = pageAiNormalizeRootRelativePath(relativePath);
|
||||
if (!root) return '';
|
||||
if (!relative) return root;
|
||||
return root + '/' + relative.split('/').map(function(segment) {
|
||||
return encodeURIComponent(segment);
|
||||
}).join('/');
|
||||
}
|
||||
|
||||
function pageAiCurrentActiveTabEditorTarget() {
|
||||
var snapshot = currentPageAiOpenEditorsSnapshot();
|
||||
if (snapshot && snapshot.activeEditor) {
|
||||
var activeTarget = pageAiTargetFromOpenEditor(snapshot.activeEditor, 'open_editors_active_tab');
|
||||
if (activeTarget) return activeTarget;
|
||||
}
|
||||
if (snapshot) {
|
||||
var entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors));
|
||||
var activeEntry = entries.find(function(entry) { return entry && entry.active === true; });
|
||||
if (activeEntry) {
|
||||
var target = pageAiTargetFromOpenEditor(activeEntry, 'open_editors_active_tab');
|
||||
if (target) return target;
|
||||
}
|
||||
}
|
||||
return pageAiFallbackEditorTarget();
|
||||
}
|
||||
|
||||
function pageAiCurrentActiveTabAddressPayload() {
|
||||
var target = pageAiCurrentActiveTabEditorTarget() || {};
|
||||
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||
var rootUri = String(workspacePath.rootUri || target.rootUri || currentRootUri() || '').trim();
|
||||
var sourceKind = String(workspacePath.sourceKind || target.sourceKind || currentSourceKind() || '').trim();
|
||||
var workspaceId = String(workspacePath.workspaceId || target.workspaceId || resolveWorkspaceId(document.body) || '').trim();
|
||||
var documentId = String(workspacePath.documentId || target.documentId || currentDocumentId() || '').trim();
|
||||
var relativePath = pageAiNormalizeRootRelativePath(workspacePath.relativePath || workspacePath.path || target.relativePath || target.path || '');
|
||||
if (!relativePath) relativePath = pageAiNormalizeRootRelativePath(localMarkdownRelativePathFromPageAiDocumentId(documentId));
|
||||
if (!documentId && relativePath && /\.(md|markdown)$/i.test(relativePath)) {
|
||||
documentId = localMarkdownDocumentIdFromPageAiRelativePath(relativePath);
|
||||
}
|
||||
if (!documentId) documentId = String(currentDocumentId() || '').trim();
|
||||
var resourceKind = String(target.resourceKind || workspacePath.resourceKind || target.editorKind || '').trim().toLowerCase();
|
||||
var editorKind = String(target.editorKind || '').trim().toLowerCase();
|
||||
var objectIdentity = String(target.objectIdentity || target.targetId || workspacePath.objectIdentity || '').trim();
|
||||
var tabUrl = new URL('/documents/' + pageAiMNoteDocumentPathSegment(documentId), window.location.origin);
|
||||
if (sourceKind) tabUrl.searchParams.set('sourceKind', sourceKind);
|
||||
if (rootUri) tabUrl.searchParams.set('rootUri', rootUri);
|
||||
if (workspaceId) tabUrl.searchParams.set('workspaceId', workspaceId);
|
||||
var isResourceTab = Boolean(relativePath)
|
||||
&& resourceKind !== 'markdown_page'
|
||||
&& resourceKind !== 'page'
|
||||
&& editorKind !== 'page';
|
||||
if (isResourceTab) {
|
||||
tabUrl.searchParams.set('resourcePath', relativePath);
|
||||
if (objectIdentity) tabUrl.searchParams.set('resourceTab', objectIdentity);
|
||||
}
|
||||
var folderRelativePath = pageAiParentRelativePath(relativePath);
|
||||
var folderUrl = pageAiFileUriForRootRelativePath(rootUri, folderRelativePath);
|
||||
if (!folderUrl) {
|
||||
var folderMNoteUrl = new URL('/documents/' + pageAiMNoteDocumentPathSegment(documentId), window.location.origin);
|
||||
if (sourceKind) folderMNoteUrl.searchParams.set('sourceKind', sourceKind);
|
||||
if (rootUri) folderMNoteUrl.searchParams.set('rootUri', rootUri);
|
||||
if (workspaceId) folderMNoteUrl.searchParams.set('workspaceId', workspaceId);
|
||||
if (folderRelativePath) folderMNoteUrl.searchParams.set('fileTreeScope', folderRelativePath);
|
||||
folderUrl = folderMNoteUrl.toString();
|
||||
}
|
||||
return {
|
||||
schema: 'mnote.page_ai.active_tab_address.v1',
|
||||
tabUrl: tabUrl.toString(),
|
||||
folderUrl: folderUrl,
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
relativePath: relativePath,
|
||||
folderRelativePath: folderRelativePath,
|
||||
targetId: String(target.targetId || objectIdentity || '').trim(),
|
||||
resourceKind: resourceKind || 'markdown_page'
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiBuildOpencodeContextPayload() {
|
||||
var selectedText = currentPageAiSelectedText();
|
||||
var target = currentPageAiEditorTarget() || {};
|
||||
@@ -1014,6 +1151,116 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pageAiOpenHubScopeSummary(scope) {
|
||||
var node = scope && typeof scope === 'object' ? scope : {};
|
||||
var workspace = node.workspaceScope && typeof node.workspaceScope === 'object' ? node.workspaceScope : {};
|
||||
var items = [];
|
||||
if (node.openhubUserKey || node.openhub_user_key) items.push('openhub_user_key=' + String(node.openhubUserKey || node.openhub_user_key));
|
||||
if (node.openhubWorkspaceKey || node.workspace_key) items.push('workspace_key=' + String(node.openhubWorkspaceKey || node.workspace_key));
|
||||
if (workspace.workspaceId) items.push('workspace=' + String(workspace.workspaceId));
|
||||
if (workspace.rootUri) items.push('rootUri=' + String(workspace.rootUri));
|
||||
if (node.openhubSessionScope || node.session_scope) items.push('session_scope=' + String(node.openhubSessionScope || node.session_scope));
|
||||
if (node.skillScope || node.skill_scope) items.push('skill_scope=' + String(node.skillScope || node.skill_scope));
|
||||
if (node.mcpScope) items.push('mcp_scope=' + String(node.mcpScope));
|
||||
if (node.toolPermissionScope || node.tool_permission_scope) items.push('tool_permission_scope=' + String(node.toolPermissionScope || node.tool_permission_scope));
|
||||
if (node.weknoraToolScope || node.weknora_tool_scope) items.push('weknora_tool_scope=' + String(node.weknoraToolScope || node.weknora_tool_scope));
|
||||
return items.join('\n');
|
||||
}
|
||||
|
||||
function pageAiOpenHubStatusText(status) {
|
||||
var node = status && typeof status === 'object' ? status : {};
|
||||
if (node.ok === true || String(node.status || '').trim() === 'ready') return '已连接 OpenHub';
|
||||
if (String(node.status || '').trim() === 'degraded') return 'OpenHub 连接受限';
|
||||
return '正在连接 OpenHub';
|
||||
}
|
||||
|
||||
function pageAiRenderOpenHubHostChrome() {
|
||||
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
if (!(drawer instanceof HTMLElement) || drawer.getAttribute('data-page-ai-openhub-host') !== 'true') return;
|
||||
var bootstrap = pageUiState.pageAiOpenHubBootstrap && typeof pageUiState.pageAiOpenHubBootstrap === 'object' ? pageUiState.pageAiOpenHubBootstrap : {};
|
||||
var scope = bootstrap.scope && typeof bootstrap.scope === 'object' ? bootstrap.scope : {};
|
||||
var status = pageUiState.pageAiOpenHubStatus && typeof pageUiState.pageAiOpenHubStatus === 'object' ? pageUiState.pageAiOpenHubStatus : {};
|
||||
var statusNode = drawer.querySelector('[data-page-ai-openhub-runtime-status]');
|
||||
if (statusNode instanceof HTMLElement) {
|
||||
statusNode.textContent = pageUiState.pageAiOpenHubStatusError || pageAiOpenHubStatusText(status);
|
||||
statusNode.setAttribute('data-state', pageUiState.pageAiOpenHubStatusError ? 'error' : (status.ok ? 'ok' : 'degraded'));
|
||||
}
|
||||
var userNode = drawer.querySelector('[data-page-ai-openhub-user-key]');
|
||||
if (userNode instanceof HTMLElement) userNode.textContent = String(scope.openhubUserKey || scope.openhub_user_key || '等待 bootstrap');
|
||||
var authNode = drawer.querySelector('[data-page-ai-openhub-auth-truth]');
|
||||
if (authNode instanceof HTMLElement) authNode.textContent = '登录真相:MNote mnote_session;拒绝 OpenHub JWT/localStorage';
|
||||
var workspaceNode = drawer.querySelector('[data-page-ai-openhub-workspace-scope]');
|
||||
if (workspaceNode instanceof HTMLElement) workspaceNode.textContent = pageAiOpenHubScopeSummary(scope) || '等待 workspace/rootUri/session/tool scope';
|
||||
var guardNode = drawer.querySelector('[data-page-ai-openhub-route-guard]');
|
||||
if (guardNode instanceof HTMLElement) guardNode.textContent = '非 AI 路由 guard:login/admin/file/knowledge 由 MNote 接管';
|
||||
var fallbackNode = drawer.querySelector('[data-page-ai-openhub-fallback]');
|
||||
if (fallbackNode instanceof HTMLElement) {
|
||||
fallbackNode.textContent = pageUiState.pageAiOpenHubFallbackReason || 'OpenHub 不可用时提示用户刷新或检查服务,不再提供 opencode 页面回退';
|
||||
}
|
||||
var iframe = drawer.querySelector('[data-page-ai-openhub-iframe]');
|
||||
if (iframe instanceof HTMLIFrameElement) {
|
||||
var nextUrl = String(pageUiState.pageAiOpenHubIframeUrl || bootstrap.openhubIframeUrl || '/page-ai/openhub/ai').trim();
|
||||
if (nextUrl && iframe.getAttribute('src') !== nextUrl) iframe.setAttribute('src', nextUrl);
|
||||
iframe.hidden = !nextUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiEnsureOpenHubHostDrawer() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
pageAiInstallMNoteOpenFileBridge();
|
||||
if (drawer.getAttribute('data-page-ai-openhub-host') !== 'true') {
|
||||
drawer.setAttribute('data-page-ai-openhub-host', 'true');
|
||||
drawer.removeAttribute('data-page-ai-opencode-host');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', 'openhub-opencode');
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-resize-handle" data-page-ai-resize-handle title="拖动调整页面 AI 宽度" aria-hidden="true"></div>' +
|
||||
'<div class="wolai-page-ai-panel wolai-page-ai-opencode-panel" role="dialog" aria-modal="false" aria-label="OpenHub AI">' +
|
||||
'<details class="wolai-page-ai-openhub-diagnostics" data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true">' +
|
||||
'<summary>诊断</summary>' +
|
||||
'<div class="wolai-page-ai-opencode-row"><span>用户隔离</span><code data-page-ai-openhub-user-key>等待 bootstrap</code></div>' +
|
||||
'<div class="wolai-page-ai-opencode-row"><span>认证边界</span><strong data-page-ai-openhub-auth-truth>登录真相:MNote mnote_session</strong></div>' +
|
||||
'<pre class="wolai-page-ai-opencode-empty" data-page-ai-openhub-workspace-scope>等待 workspace/rootUri/session/tool scope</pre>' +
|
||||
'<div class="wolai-page-ai-opencode-badges"><span data-page-ai-openhub-route-guard>非 AI 路由 guard:login/admin/file/knowledge</span><span data-page-ai-openhub-fallback>OpenHub 不可用时提示刷新或检查服务</span></div>' +
|
||||
'</details>' +
|
||||
'<div class="wolai-page-ai-opencode-frame-wrap" data-page-ai-openhub-frame-wrap>' +
|
||||
'<iframe class="wolai-page-ai-opencode-iframe" data-page-ai-openhub-iframe title="OpenHub AI 面板" allow="clipboard-read; clipboard-write" referrerpolicy="same-origin" src="about:blank"></iframe>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
void pageAiBootstrapOpenHubHost();
|
||||
return drawer;
|
||||
}
|
||||
|
||||
async function pageAiBootstrapOpenHubHost() {
|
||||
var context = pageAiBuildOpencodeContextPayload();
|
||||
try {
|
||||
var statusResponse = await fetch('/api/page-ai/openhub/status', { headers: { accept: 'application/json' }, cache: 'no-store' });
|
||||
pageUiState.pageAiOpenHubStatus = await statusResponse.json().catch(function() { return null; }) || {};
|
||||
var response = await fetch('/api/page-ai/openhub/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify(context)
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(payload && (payload.error || payload.message) ? String(payload.error || payload.message) : 'HTTP ' + response.status);
|
||||
pageUiState.pageAiOpenHubBootstrap = payload || {};
|
||||
pageUiState.pageAiOpenHubStatusError = '';
|
||||
pageUiState.pageAiOpenHubIframeUrl = String(payload && payload.openhubIframeUrl || '/page-ai/openhub/ai').trim();
|
||||
var statusFallback = pageUiState.pageAiOpenHubStatus && pageUiState.pageAiOpenHubStatus.fallback ? pageUiState.pageAiOpenHubStatus.fallback : null;
|
||||
pageUiState.pageAiOpenHubFallbackReason = payload && payload.fallback
|
||||
? String(payload.fallback.reason || '')
|
||||
: (statusFallback ? String(statusFallback.reason || '') : '');
|
||||
pageUiState.pageAiOpenHubScopeSummary = pageAiOpenHubScopeSummary(payload && payload.scope);
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiOpenHubStatusError = error instanceof Error ? error.message : String(error);
|
||||
pageUiState.pageAiOpenHubFallbackReason = 'OpenHub bootstrap 不可用,请刷新或检查 OpenHub 服务';
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiPostOpencodeBridgeMessage(type, payload) {
|
||||
var iframe = document.querySelector('[data-page-ai-opencode-iframe]');
|
||||
if (!(iframe instanceof HTMLIFrameElement) || !iframe.contentWindow) return false;
|
||||
@@ -1021,6 +1268,41 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiInstallMNoteOpenFileBridge() {
|
||||
if (pageUiState.pageAiMNoteOpenFileBridgeInstalled) return;
|
||||
pageUiState.pageAiMNoteOpenFileBridgeInstalled = true;
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
var message = event.data && typeof event.data === 'object' ? event.data : null;
|
||||
if (message && message.source === 'openhub-ai' && message.type === 'mnote:get-active-tab-address') {
|
||||
var addressPayload = pageAiCurrentActiveTabAddressPayload();
|
||||
var requestPayload = message.payload && typeof message.payload === 'object' ? message.payload : {};
|
||||
var kind = String(requestPayload.kind || message.kind || 'tab').trim();
|
||||
var value = kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl;
|
||||
if (event.source && typeof event.source.postMessage === 'function') {
|
||||
event.source.postMessage({
|
||||
source: 'mnote-page-ai',
|
||||
type: 'mnote:active-tab-address',
|
||||
requestId: message.requestId || '',
|
||||
payload: Object.assign({}, addressPayload, {
|
||||
kind: kind === 'folder' ? 'folder' : 'tab',
|
||||
value: value
|
||||
})
|
||||
}, event.origin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!message || (message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference')) return;
|
||||
if (message.source !== 'openhub-diff' && message.source !== 'openhub-changed-files' && message.source !== 'openhub-citation') return;
|
||||
var payload = message.payload && typeof message.payload === 'object' ? message.payload : {};
|
||||
pageAiOpenOpencodeChangedFile(message.path || payload.path || payload.href || '', {
|
||||
rootUri: String(message.rootUri || payload.rootUri || payload.root_uri || currentRootUri() || '').trim(),
|
||||
workspaceId: String(message.workspaceId || payload.workspaceId || payload.workspace_id || resolveWorkspaceId(document.body) || '').trim(),
|
||||
documentId: String(message.documentId || payload.documentId || payload.document_id || '').trim()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiInstallOpencodeBridge() {
|
||||
if (pageUiState.pageAiOpencodeBridgeInstalled) return;
|
||||
pageUiState.pageAiOpencodeBridgeInstalled = true;
|
||||
@@ -1254,8 +1536,10 @@ export function createSidebarPageAiRuntime(context) {
|
||||
|
||||
function pageAiEnsureOpencodeHostDrawer() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
pageAiInstallMNoteOpenFileBridge();
|
||||
if (drawer.getAttribute('data-page-ai-opencode-host') !== 'true') {
|
||||
drawer.setAttribute('data-page-ai-opencode-host', 'true');
|
||||
drawer.removeAttribute('data-page-ai-openhub-host');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', 'opencode');
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-resize-handle" data-page-ai-resize-handle title="拖动调整页面 AI 宽度" aria-hidden="true"></div>' +
|
||||
@@ -1530,16 +1814,59 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiRenderOpencodeHostChrome();
|
||||
}
|
||||
|
||||
function pageAiOpenOpencodeChangedFile(path) {
|
||||
var targetPath = String(path || '').trim();
|
||||
if (!targetPath || !window.__mnoteDocumentPaneRuntime || typeof window.__mnoteDocumentPaneRuntime.openResourceInActiveTab !== 'function') return false;
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path: targetPath });
|
||||
function pageAiOpenOpencodeChangedFile(path, options) {
|
||||
var rootUri = String(options && options.rootUri || currentRootUri() || '').trim();
|
||||
var targetPath = pageAiRootRelativePathFromChangedPath(path, rootUri);
|
||||
if (!targetPath || !window.__mnoteDocumentPaneRuntime) return false;
|
||||
var documentId = String(options && options.documentId || pageAiDocumentIdFromRootRelativePath(targetPath) || '').trim();
|
||||
var workspaceId = String(options && options.workspaceId || resolveWorkspaceId(document.body) || '').trim();
|
||||
var isMarkdown = Boolean(documentId);
|
||||
if (isMarkdown) {
|
||||
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') {
|
||||
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({
|
||||
source: 'openhub_changed_file_bridge',
|
||||
reason: 'openhub_changed_file',
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
payload: {
|
||||
schema: 'mnote.local_folder.watch_batch.v1',
|
||||
source: 'openhub_changed_file_bridge',
|
||||
rootUri: rootUri,
|
||||
changedPaths: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }],
|
||||
affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'openhub_changed_file' }]
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument === 'function') {
|
||||
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({
|
||||
documentId: documentId,
|
||||
workspaceId: workspaceId,
|
||||
source: 'openhub-changed-file-bridge'
|
||||
});
|
||||
}
|
||||
if (typeof window.__mnoteDocumentPaneRuntime.openPrimaryDocument === 'function') {
|
||||
void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
|
||||
documentId: documentId,
|
||||
workspaceId: workspaceId,
|
||||
sourceKind: currentSourceKind() || 'local_folder',
|
||||
rootUri: rootUri
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-openhub-document-pane-refresh', targetPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (typeof window.__mnoteDocumentPaneRuntime.openResourceInActiveTab !== 'function') return false;
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path: targetPath, rootUri: rootUri });
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiRefreshOpencodeCurrentPage(reason) {
|
||||
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument === 'function') {
|
||||
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: reason || 'page-ai-opencode-host' });
|
||||
if (reason) {
|
||||
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: reason });
|
||||
} else {
|
||||
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: 'page-ai-opencode-host' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1739,7 +2066,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
'mnote.selection.read',
|
||||
'mnote.open_resources.snapshot',
|
||||
'mnote.allowed_roots.describe',
|
||||
'mnote.lightrag.query',
|
||||
'mnote.weknora.search',
|
||||
'mnote.reference.open',
|
||||
'mnote.page_aggregate.snapshot',
|
||||
'mnote.local_file.receipt'
|
||||
@@ -3540,6 +3867,20 @@ export function createSidebarPageAiRuntime(context) {
|
||||
|
||||
|
||||
function openPageAiDrawer() {
|
||||
if (pageAiOpenHubHostEnabled()) {
|
||||
pageAiEnsureContextRefState();
|
||||
void pageAiLoadAllowedRoots().then(function() {
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
}).catch(function() {
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
});
|
||||
var openhubDrawer = pageAiEnsureOpenHubHostDrawer();
|
||||
pageAiApplyDrawerWidth(openhubDrawer);
|
||||
openhubDrawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
updatePageAiTriggerState();
|
||||
return;
|
||||
}
|
||||
if (pageAiOpencodeHostEnabled()) {
|
||||
pageAiEnsureContextRefState();
|
||||
void pageAiLoadAllowedRoots().then(function() {
|
||||
@@ -3748,7 +4089,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
if (!asksCitation) return false;
|
||||
return ['lightrag', '资料库', '知识库', 'rag', '文献', '论文', '保护基'].some(function(word) {
|
||||
return ['weknora', '资料库', '知识库', 'rag', '文献', '论文', '保护基'].some(function(word) {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
}
|
||||
@@ -3759,7 +4100,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
var afterColon = text.split(/[::]/).slice(1).join(':').trim();
|
||||
var candidate = afterColon || text;
|
||||
candidate = candidate
|
||||
.replace(/^(请|请用|帮我|帮忙|用)?(资料库|知识库|lightrag|rag)?(搜索|检索|查找|查询|回答|说明|解释|总结)?/i, '')
|
||||
.replace(/^(请|请用|帮我|帮忙|用)?(资料库|知识库|weknora|rag)?(搜索|检索|查找|查询|回答|说明|解释|总结)?/i, '')
|
||||
.replace(/(请)?(给出|给我|附上|提供)?(链接|引用|来源|出处|证据|定位).*$/i, '')
|
||||
.trim();
|
||||
var stopIndex = candidate.search(/(在|的|是|有哪些|有什么|如何|怎么|用于|用途|作用|资料|文献|论文)/);
|
||||
@@ -3885,7 +4226,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
} catch (error) {
|
||||
console.warn('MNote Page AI 自动追加 LightRAG 引用失败', error);
|
||||
console.warn('MNote Page AI 自动追加知识库引用失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4749,6 +5090,9 @@ export function createSidebarPageAiRuntime(context) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (action === 'openhub-refresh-bootstrap') {
|
||||
void pageAiBootstrapOpenHubHost();
|
||||
}
|
||||
if (action === 'cancel-queued-run') {
|
||||
void pageAiCancelQueuedRun(pageAiAction.getAttribute('data-page-ai-queue-id'));
|
||||
}
|
||||
@@ -5159,7 +5503,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
var api = {
|
||||
ensurePageAiStateFacade: (...args) => ensurePageAiStateFacade(...args),
|
||||
installPageAiDelegates: (...args) => installPageAiDelegates(...args),
|
||||
handlePageAiClick: (...args) => handlePageAiClick(...args),
|
||||
@@ -5217,4 +5561,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiRefreshOpencodeCurrentPage: (...args) => pageAiRefreshOpencodeCurrentPage(...args),
|
||||
updatePageAiTriggerState: (...args) => updatePageAiTriggerState(...args)
|
||||
};
|
||||
window.__mnoteSidebarPageAiRuntime = api;
|
||||
return api;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -210,6 +210,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args);
|
||||
const openPageIndexSettingsPopover = (...args) => sidebarPageSettings.openPageIndexSettingsPopover(...args);
|
||||
const openKnowledgeRagSettingsPopover = (...args) => sidebarPageSettings.openKnowledgeRagSettingsPopover(...args);
|
||||
const openKnowledgeRagSourceReference = (...args) => sidebarPageSettings.openKnowledgeRagSourceReference(...args);
|
||||
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
|
||||
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
|
||||
const addLocalIndexRange = (...args) => sidebarPageSettings.addLocalIndexRange(...args);
|
||||
@@ -219,6 +220,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const ingestSingleKnowledgeRagSource = (...args) => sidebarPageSettings.ingestSingleKnowledgeRagSource(...args);
|
||||
const loadKnowledgeRagStatus = (...args) => sidebarPageSettings.loadKnowledgeRagStatus(...args);
|
||||
const openKnowledgeRagDashboard = (...args) => sidebarPageSettings.openKnowledgeRagDashboard(...args);
|
||||
const createKnowledgeRagKnowledgeBase = (...args) => sidebarPageSettings.createKnowledgeRagKnowledgeBase(...args);
|
||||
const setActiveKnowledgeRagDetailTab = (...args) => sidebarPageSettings.setActiveKnowledgeRagDetailTab(...args);
|
||||
const performKnowledgeRagSearch = (...args) => sidebarPageSettings.performKnowledgeRagSearch(...args);
|
||||
const saveKnowledgeRagSearchParams = (...args) => sidebarPageSettings.saveKnowledgeRagSearchParams(...args);
|
||||
@@ -227,6 +229,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const refreshDashboardPanel = (...args) => sidebarPageSettings.refreshDashboardPanel(...args);
|
||||
|
||||
const pruneKnowledgeRagRegistry = (...args) => sidebarPageSettings.pruneKnowledgeRagRegistry(...args);
|
||||
const setKnowledgeRagSelectedProviderKnowledgeBaseId = (...args) => sidebarPageSettings.setKnowledgeRagSelectedProviderKnowledgeBaseId(...args);
|
||||
const setKnowledgeRagSourceFilter = (...args) => sidebarPageSettings.setKnowledgeRagSourceFilter(...args);
|
||||
const useKnowledgeRagFileTreeSelection = (...args) => sidebarPageSettings.useKnowledgeRagFileTreeSelection(...args);
|
||||
const persistLocalIndexSettings = (...args) => sidebarPageSettings.persistLocalIndexSettings(...args);
|
||||
@@ -3055,6 +3058,19 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
void loadKnowledgeRagStatus(true);
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'create-kb') {
|
||||
void createKnowledgeRagKnowledgeBase();
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'focus-create-kb') {
|
||||
var createKbInput = document.querySelector('[data-knowledge-rag-create-kb-name]');
|
||||
if (createKbInput instanceof HTMLInputElement) createKbInput.focus();
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'select-kb') {
|
||||
setKnowledgeRagSelectedProviderKnowledgeBaseId(knowledgeRagAction.getAttribute('data-knowledge-rag-kb-id') || '');
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'add-source') {
|
||||
addKnowledgeRagSourceInput();
|
||||
return;
|
||||
@@ -3079,6 +3095,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
void deleteKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'open-source-reference') {
|
||||
openKnowledgeRagSourceReference(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'prune') {
|
||||
void pruneKnowledgeRagRegistry();
|
||||
return;
|
||||
@@ -3590,6 +3610,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (pageWidthSelect instanceof HTMLSelectElement) {
|
||||
var pageWidthType = pageWidthSelect.getAttribute('data-page-width-select') || '';
|
||||
void persistPageWidthPreference(pageWidthType, pageWidthSelect.value);
|
||||
return;
|
||||
}
|
||||
var knowledgeRagKbSelect = closestAction(event.target, '[data-knowledge-rag-kb-select]');
|
||||
if (knowledgeRagKbSelect instanceof HTMLSelectElement) {
|
||||
setKnowledgeRagSelectedProviderKnowledgeBaseId(knowledgeRagKbSelect.value);
|
||||
}
|
||||
});
|
||||
sidebarPageAi.installPageAiDelegates();
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::knowledge_rag::{
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSectionContextRequest,
|
||||
KnowledgeRagStatusQuery,
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
|
||||
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use serde_json::{json, Value};
|
||||
@@ -36,22 +36,37 @@ pub async fn status(
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
inject_identity_args(&mut args, input);
|
||||
let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_weknora_search_payload_invalid",
|
||||
format!("WeKnora 检索参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let Json(payload) = crate::routes::knowledge_rag::search(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Json(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn query(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
inject_identity_args(&mut args, input);
|
||||
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_query_payload_invalid",
|
||||
@@ -74,16 +89,8 @@ pub async fn open_reference(
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
inject_identity_args(&mut args, input);
|
||||
let body =
|
||||
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -107,16 +114,7 @@ pub async fn section_context(
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
inject_identity_args(&mut args, input);
|
||||
let body =
|
||||
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -134,6 +132,109 @@ pub async fn section_context(
|
||||
Ok(compact_section_context_for_agent(payload))
|
||||
}
|
||||
|
||||
pub async fn list_sources(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
let payload = status(state, context, input).await?;
|
||||
Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.weknora.sources_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null),
|
||||
"registry": payload.get("registry").cloned().unwrap_or(Value::Null),
|
||||
"documents": payload.get("documents").cloned().unwrap_or(Value::Null),
|
||||
"locatorPolicy": "WeKnora provider ids are returned separately; filenames and chunk ids are not local paths.",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_source_status(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
let payload = list_sources(state, context, input).await?;
|
||||
let requested_source = args
|
||||
.get("sourcePath")
|
||||
.or_else(|| args.get("providerKnowledgeId"))
|
||||
.or_else(|| args.get("providerSourceId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let entries = payload
|
||||
.pointer("/registry/entries")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let matched = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
requested_source.is_empty()
|
||||
|| entry
|
||||
.get("sourceRootRelativePath")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == requested_source)
|
||||
|| entry
|
||||
.get("sourceId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == requested_source)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.weknora.source_status_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"source": if matched.len() == 1 { matched[0].clone() } else { Value::Null },
|
||||
"sources": matched,
|
||||
"requestedSource": requested_source,
|
||||
}))
|
||||
}
|
||||
|
||||
fn inject_identity_args(args: &mut Value, input: &ToolCallInput) {
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_weknora_scope(
|
||||
args: &Value,
|
||||
input: &ToolCallInput,
|
||||
context: &RequestContext,
|
||||
) -> Result<(), WebError> {
|
||||
let has_root_uri = args
|
||||
.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| input.effective_root_uri().is_some();
|
||||
let has_scope = args.get("scope").is_some()
|
||||
|| args.get("allowlist").is_some()
|
||||
|| args.get("allowedRoots").is_some()
|
||||
|| args.get("aiAccessScope").is_some()
|
||||
|| args
|
||||
.get("sourcePaths")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| !items.is_empty());
|
||||
if has_root_uri && has_scope {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
"mnote_weknora_scope_required",
|
||||
"WeKnora tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
let references = payload
|
||||
.get("references")
|
||||
@@ -181,7 +282,7 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.knowledge_rag.agent_query_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
|
||||
"references": references,
|
||||
"citations": citations,
|
||||
@@ -351,7 +452,7 @@ fn compact_section_context_for_agent(payload: Value) -> Value {
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
|
||||
@@ -382,7 +483,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
.unwrap_or_else(|| quote_diagnostics("e));
|
||||
json!({
|
||||
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
|
||||
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
|
||||
@@ -416,7 +517,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
@@ -490,7 +591,7 @@ mod tests {
|
||||
fn compact_query_result_marks_post_filter_scope_without_raw_chunks() {
|
||||
let payload = json!({
|
||||
"ok": true,
|
||||
"provider": "lightrag",
|
||||
"provider": "lightrag_legacy",
|
||||
"sourceScope": ["docs/a.md"],
|
||||
"sourceScopeMode": "post_filter_mapped_references",
|
||||
"rawScopeFiltered": false,
|
||||
@@ -522,7 +623,9 @@ mod tests {
|
||||
assert!(compact.get("chunks").is_none());
|
||||
assert_eq!(
|
||||
compact["rawMetadataMeaning"].as_str(),
|
||||
Some("provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote")
|
||||
Some(
|
||||
"provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote"
|
||||
)
|
||||
);
|
||||
assert!(compact["answerGuidance"]
|
||||
.as_str()
|
||||
|
||||
@@ -57,6 +57,10 @@ fn doc_tools() -> Vec<Value> {
|
||||
fn knowledge_rag_tools() -> Vec<Value> {
|
||||
vec![
|
||||
knowledge_rag_status_tool(),
|
||||
weknora_search_tool(),
|
||||
weknora_list_sources_tool(),
|
||||
weknora_get_source_status_tool(),
|
||||
weknora_open_reference_tool(),
|
||||
knowledge_rag_query_tool(),
|
||||
knowledge_rag_section_context_tool(),
|
||||
knowledge_rag_open_reference_tool(),
|
||||
@@ -355,7 +359,7 @@ fn knowledge_rag_status_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.status",
|
||||
"description": "查看 LightRAG 资料库 provider 状态、dashboard 地址、source registry 和同步状态。",
|
||||
"description": "查看当前资料库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 WeKnora;LightRAG 仅作为 legacy fallback。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -367,6 +371,114 @@ fn knowledge_rag_status_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_scope_properties() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("scope".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"allowlist".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
map.insert("allowedRoots".into(), json!({ "type": "array" }));
|
||||
map.insert("aiAccessScope".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"sourcePaths".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
}
|
||||
properties
|
||||
}
|
||||
|
||||
fn weknora_search_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 10 }));
|
||||
map.insert(
|
||||
"includeChunkContent".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.search",
|
||||
"description": "只读调用 WeKnora 检索。必须带 rootUri 和 scope/allowlist,返回 provider ids、MNote source registry 映射以及 locatorDegraded;不要把 WeKnora filename/chunk id 当成本地 path。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_list_sources_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.weknora.list_sources",
|
||||
"description": "只读列出 MNote source registry 与 WeKnora provider 状态。必须带 rootUri 和 scope/allowlist。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri"],
|
||||
"properties": weknora_scope_properties()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_get_source_status_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("sourcePath".into(), json!({ "type": "string" }));
|
||||
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
|
||||
map.insert("providerSourceId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.get_source_status",
|
||||
"description": "只读查询某个 MNote source 或 WeKnora provider knowledge id 的映射状态。必须带 rootUri 和 scope/allowlist。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_open_reference_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("reference".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"providerKnowledgeBaseId".into(),
|
||||
json!({ "type": "string" }),
|
||||
);
|
||||
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
|
||||
map.insert("providerChunkId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.open_reference",
|
||||
"description": "只读把 WeKnora reference 映射为 MNote open action。provider ids 独立返回;未命中 source registry 时必须视为 locatorDegraded。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "reference"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_query_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -391,7 +503,7 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
json!({
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
|
||||
"description": "返回由 provider sidecar/headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
|
||||
}),
|
||||
);
|
||||
map.insert(
|
||||
@@ -399,13 +511,13 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
json!({
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 references;provider raw 仍可能是全局结果。"
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后,MNote 只过滤返回的 references;provider raw 仍可能是全局结果。"
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.query",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"description": "向当前资料库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 WeKnora,LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -429,7 +541,7 @@ fn knowledge_rag_open_reference_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.open_reference",
|
||||
"description": "把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
|
||||
"description": "把 provider reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -474,7 +586,7 @@ fn knowledge_rag_section_context_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.section_context",
|
||||
"description": "按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
|
||||
"description": "按 documentStructureIndex section 的 block/paragraph range,从 provider sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
|
||||
@@ -38,8 +38,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-knowledge-rag",
|
||||
title: "资料库问答",
|
||||
description:
|
||||
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
|
||||
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源;默认 provider 是 WeKnora。",
|
||||
category: "knowledge",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: true,
|
||||
@@ -47,6 +46,10 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
tool_names: &[
|
||||
"mnote.context.snapshot",
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.weknora.search",
|
||||
"mnote.weknora.list_sources",
|
||||
"mnote.weknora.get_source_status",
|
||||
"mnote.weknora.open_reference",
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.section_context",
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod hermes_tools;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
pub mod provider_identity_sync;
|
||||
pub mod routes;
|
||||
pub mod ssr;
|
||||
pub mod transport;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use crate::error::WebError;
|
||||
use control_plane::UserRecord;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::env;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_OPENHUB_BASE_URL: &str = "http://127.0.0.1:18080";
|
||||
const DEFAULT_WEKNORA_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1";
|
||||
const PROVIDER_IDENTITY_SYNC_TIMEOUT_MS: u64 = 1_500;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderIdentitySyncResult {
|
||||
pub provider: &'static str,
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
pub provider_user_id: Option<String>,
|
||||
}
|
||||
|
||||
fn env_flag(key: &str, default: bool) -> bool {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn clean_url(value: String) -> Option<String> {
|
||||
let trimmed = value.trim().trim_end_matches('/').to_string();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
fn openhub_base_url() -> String {
|
||||
env::var("MNOTE_OPENHUB_BASE_URL")
|
||||
.ok()
|
||||
.and_then(clean_url)
|
||||
.unwrap_or_else(|| DEFAULT_OPENHUB_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn weknora_endpoint() -> String {
|
||||
env::var("MNOTE_WEKNORA_ENDPOINT")
|
||||
.or_else(|_| env::var("WEKNORA_ENDPOINT"))
|
||||
.ok()
|
||||
.and_then(clean_url)
|
||||
.unwrap_or_else(|| DEFAULT_WEKNORA_ENDPOINT.to_string())
|
||||
}
|
||||
|
||||
fn join_url(base_url: &str, path: &str) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn internal_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(secret) = env::var("MNOTE_PROVIDER_IDENTITY_SYNC_SECRET")
|
||||
.or_else(|_| env::var("MNOTE_INTERNAL_API_SECRET"))
|
||||
.or_else(|_| env::var("INTERNAL_API_SECRET"))
|
||||
{
|
||||
if let Ok(value) = HeaderValue::from_str(secret.trim()) {
|
||||
headers.insert("x-internal-token", value);
|
||||
}
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn stable_openhub_user_id(user_id: &str) -> i64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
format!("mnote-openhub-user:{user_id}").hash(&mut hasher);
|
||||
(hasher.finish() % 1_900_000_000) as i64 + 100_000_000
|
||||
}
|
||||
|
||||
fn fallback_email(user: &UserRecord) -> String {
|
||||
user.email
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("{}@mnote.local", user.username))
|
||||
}
|
||||
|
||||
fn default_workspace_path(user: &UserRecord) -> String {
|
||||
format!(
|
||||
"/mnt/Data1T/Mnote_data/users/{}/workspaces/my-space",
|
||||
user.id
|
||||
)
|
||||
}
|
||||
|
||||
fn sync_disabled() -> bool {
|
||||
!env_flag("MNOTE_PROVIDER_IDENTITY_SYNC", true)
|
||||
}
|
||||
|
||||
pub async fn sync_provider_identities(
|
||||
user: &UserRecord,
|
||||
password: &str,
|
||||
) -> Vec<ProviderIdentitySyncResult> {
|
||||
if sync_disabled() {
|
||||
return vec![ProviderIdentitySyncResult {
|
||||
provider: "all",
|
||||
ok: true,
|
||||
message: "provider identity sync disabled".to_string(),
|
||||
provider_user_id: None,
|
||||
}];
|
||||
}
|
||||
|
||||
let sync_openhub = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_OPENHUB", true);
|
||||
let sync_weknora = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_WEKNORA", true);
|
||||
|
||||
match (sync_openhub, sync_weknora) {
|
||||
(true, true) => {
|
||||
let (openhub, weknora) = tokio::join!(
|
||||
sync_openhub_identity(user, password),
|
||||
sync_weknora_identity(user, password)
|
||||
);
|
||||
vec![openhub, weknora]
|
||||
}
|
||||
(true, false) => vec![sync_openhub_identity(user, password).await],
|
||||
(false, true) => vec![sync_weknora_identity(user, password).await],
|
||||
(false, false) => vec![ProviderIdentitySyncResult {
|
||||
provider: "all",
|
||||
ok: true,
|
||||
message: "provider identity sync disabled".to_string(),
|
||||
provider_user_id: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_json(url: String, body: Value) -> Result<Value, WebError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(PROVIDER_IDENTITY_SYNC_TIMEOUT_MS))
|
||||
.build()
|
||||
.map_err(|error| WebError::internal(format!("provider identity sync client: {error}")))?;
|
||||
let response = client
|
||||
.post(url.clone())
|
||||
.headers(internal_headers())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"provider_identity_sync_unreachable",
|
||||
format!("{url}: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
|
||||
if !status.is_success() {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"provider_identity_sync_failed",
|
||||
format!("{url} returned {status}: {payload}"),
|
||||
));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn sync_openhub_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
|
||||
let provider_user_id = stable_openhub_user_id(&user.id);
|
||||
let payload = json!({
|
||||
"provider_user_id": provider_user_id,
|
||||
"mnote_user_id": user.id,
|
||||
"username": user.username,
|
||||
"email": fallback_email(user),
|
||||
"password": password,
|
||||
"workspace_path": default_workspace_path(user),
|
||||
"disabled": user.status != "active",
|
||||
"is_admin": false,
|
||||
});
|
||||
match post_json(
|
||||
join_url(&openhub_base_url(), "/api/internal/mnote/users/provision"),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: true,
|
||||
message: "synced".to_string(),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
},
|
||||
Err(error) => ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: false,
|
||||
message: error.message().to_string(),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
|
||||
let payload = json!({
|
||||
"mnote_user_id": user.id,
|
||||
"username": user.username,
|
||||
"email": fallback_email(user),
|
||||
"password": password,
|
||||
"role": "contributor",
|
||||
"is_active": user.status == "active",
|
||||
});
|
||||
match post_json(
|
||||
join_url(&weknora_endpoint(), "/internal/mnote/users/provision"),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => ProviderIdentitySyncResult {
|
||||
provider: "weknora",
|
||||
ok: true,
|
||||
message: "synced".to_string(),
|
||||
provider_user_id: payload
|
||||
.pointer("/user/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
Err(error) => ProviderIdentitySyncResult {
|
||||
provider: "weknora",
|
||||
ok: false,
|
||||
message: error.message().to_string(),
|
||||
provider_user_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::provider_identity_sync::sync_provider_identities;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
@@ -136,7 +137,7 @@ pub async fn auth_api(
|
||||
return Ok(build_sqlite_sign_out_response(&state, &context));
|
||||
}
|
||||
|
||||
handle_sqlite_auth_action(&state, &context, &payload)
|
||||
handle_sqlite_auth_action(&state, &context, &payload).await
|
||||
}
|
||||
|
||||
pub async fn auth_entry(
|
||||
@@ -2069,7 +2070,7 @@ fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
|
||||
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
|
||||
}
|
||||
|
||||
fn handle_sqlite_auth_action(
|
||||
async fn handle_sqlite_auth_action(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
payload: &serde_json::Value,
|
||||
@@ -2098,6 +2099,7 @@ fn handle_sqlite_auth_action(
|
||||
})?;
|
||||
let session_token = new_session_token();
|
||||
let token_hash = session_token_hash(&session_token);
|
||||
let password_for_provider_sync = password.clone();
|
||||
let resolved = if flow == "signUp" {
|
||||
let email = params
|
||||
.get("email")
|
||||
@@ -2200,14 +2202,46 @@ fn handle_sqlite_auth_action(
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
});
|
||||
|
||||
Ok(build_sqlite_auth_response(
|
||||
let provider_sync_results = if flow == "signUp" {
|
||||
let results = sync_provider_identities(&resolved.user, &password_for_provider_sync).await;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(resolved.user.id.clone()),
|
||||
action: "control.auth.provider_identities_synced".to_string(),
|
||||
target_kind: "user".to_string(),
|
||||
target_id: Some(resolved.user.id.clone()),
|
||||
metadata_json: serde_json::to_string(&json!({
|
||||
"providers": results.iter().map(|item| json!({
|
||||
"provider": item.provider,
|
||||
"ok": item.ok,
|
||||
"message": item.message,
|
||||
"providerUserId": item.provider_user_id,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
});
|
||||
Some(results)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut response = build_sqlite_auth_response(
|
||||
context,
|
||||
&session_token,
|
||||
&resolved.user.id,
|
||||
resolved.user.email.as_deref().unwrap_or_default(),
|
||||
&resolved.user.display_name,
|
||||
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
|
||||
))
|
||||
);
|
||||
if let Some(results) = provider_sync_results {
|
||||
let all_ok = results.iter().all(|item| item.ok);
|
||||
let header_value = if all_ok { "ok" } else { "partial" };
|
||||
if let Ok(value) = HeaderValue::from_str(header_value) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-mnote-provider-identity-sync", value);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn sqlite_auth_error(
|
||||
|
||||
@@ -368,11 +368,21 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_evidence_tools_retired",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.weknora.search/open_reference 或兼容 mnote.knowledge_rag.query/open_reference",
|
||||
)
|
||||
.with_context(&context))
|
||||
}
|
||||
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
|
||||
"mnote.weknora.search" => knowledge_rag::search(&state, &context, &input).await,
|
||||
"mnote.weknora.list_sources" => {
|
||||
knowledge_rag::list_sources(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.get_source_status" => {
|
||||
knowledge_rag::get_source_status(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.open_reference" => {
|
||||
knowledge_rag::open_reference(&state, &context, &input).await
|
||||
}
|
||||
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
|
||||
"mnote.knowledge_rag.section_context" => {
|
||||
knowledge_rag::section_context(&state, &context, &input).await
|
||||
@@ -384,7 +394,7 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_index_tools_retired",
|
||||
"旧本地索引 tools 已退役;资料索引统一由 LightRAG provider 处理",
|
||||
"旧本地索引 tools 已退役;资料索引统一由当前知识库 provider 处理",
|
||||
)
|
||||
.with_context(&context),
|
||||
),
|
||||
@@ -728,6 +738,10 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
@@ -761,6 +775,10 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ mod onlyoffice;
|
||||
pub(crate) mod onlyoffice_bridge;
|
||||
mod page_ai_board;
|
||||
mod page_ai_opencode;
|
||||
mod page_ai_openhub;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
mod resource_trash;
|
||||
@@ -91,6 +92,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/knowledge-rag/pipeline-events",
|
||||
get(knowledge_rag::pipeline_events),
|
||||
)
|
||||
.route(
|
||||
"/api/knowledge-rag/knowledge-bases",
|
||||
post(knowledge_rag::create_knowledge_base),
|
||||
)
|
||||
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
|
||||
.route("/api/knowledge-rag/search", post(knowledge_rag::search))
|
||||
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
|
||||
@@ -389,6 +394,68 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/page-ai/opencode/status",
|
||||
get(page_ai_opencode::status),
|
||||
)
|
||||
.route("/api/page-ai/openhub/status", get(page_ai_openhub::status))
|
||||
.route(
|
||||
"/api/page-ai/openhub/bootstrap",
|
||||
post(page_ai_openhub::bootstrap),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/openhub/artifact-index",
|
||||
get(page_ai_openhub::artifact_index_get).post(page_ai_openhub::artifact_index_upsert),
|
||||
)
|
||||
.route("/page-ai/openhub/ai", get(page_ai_openhub::ai_shell))
|
||||
.route(
|
||||
"/page-ai/openhub/ai/{*path}",
|
||||
any(page_ai_openhub::ai_proxy),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/login",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/login/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/admin",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/admin/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/file",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/file/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/files",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/files/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/knowledge",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/knowledge/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/git",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/git/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/session",
|
||||
post(page_ai_opencode::bind_session),
|
||||
@@ -452,16 +519,31 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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-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(
|
||||
"/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(
|
||||
"/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))
|
||||
@@ -472,10 +554,19 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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(
|
||||
"/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(
|
||||
"/{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(
|
||||
|
||||
@@ -142,7 +142,9 @@ fn file_uri_to_path(root_uri: &str) -> Option<String> {
|
||||
Some(percent_decode_path_lossy(&normalized))
|
||||
}
|
||||
|
||||
fn opencode_project_directory_for_request(request: Option<&OpencodeSessionRequest>) -> Option<String> {
|
||||
fn opencode_project_directory_for_request(
|
||||
request: Option<&OpencodeSessionRequest>,
|
||||
) -> Option<String> {
|
||||
if let Some(override_directory) = std::env::var("MNOTE_OPENCODE_PROJECT_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
@@ -1025,6 +1027,23 @@ async fn proxy_to_opencode(
|
||||
body: Body,
|
||||
) -> Result<Response, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
if std::env::var("MNOTE_PAGE_AI_OPENCODE_LEGACY_PROXY")
|
||||
.ok()
|
||||
.map(|value| value.trim() == "1")
|
||||
.unwrap_or(false)
|
||||
!= true
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from("page_ai_opencode_legacy_proxy_disabled"))
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!(
|
||||
"opencode legacy proxy disabled response 构造失败: {error}"
|
||||
))
|
||||
.with_context(&context)
|
||||
});
|
||||
}
|
||||
let client =
|
||||
opencode_client(Duration::from_secs(120)).map_err(|error| error.with_context(&context))?;
|
||||
let url = upstream_url(&path, uri.query()).map_err(|error| error.with_context(&context))?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2530,9 +2530,536 @@
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-settings-panel {
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
width: min(1120px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 72px);
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: #F6F7F9;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-eyebrow {
|
||||
color: #2F7D4A;
|
||||
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-title strong {
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-title span:last-child {
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-actions button {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: #4B5563;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-actions button:hover:not(:disabled) {
|
||||
background: #F3F4F6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-status {
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
color: #4B5563;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
min-height: 620px;
|
||||
max-height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-pane,
|
||||
.mnote-weknora-kb-detail-pane {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-right: 1px solid #E5E7EB;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head strong {
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head span {
|
||||
min-width: 22px;
|
||||
border-radius: 999px;
|
||||
background: #EEF2FF;
|
||||
color: #3730A3;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head button,
|
||||
.mnote-weknora-doc-toolbar button,
|
||||
.mnote-weknora-doc-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head button {
|
||||
width: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head button:hover,
|
||||
.mnote-weknora-doc-toolbar button:hover,
|
||||
.mnote-weknora-doc-actions button:hover {
|
||||
background: #F3F4F6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card.is-active {
|
||||
border-color: #2F7D4A;
|
||||
background: #F0F9F4;
|
||||
box-shadow: inset 3px 0 0 #2F7D4A;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-star {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #EEF2FF;
|
||||
color: #3730A3;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main strong,
|
||||
.mnote-weknora-kb-card-main em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main strong {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main em {
|
||||
color: #6B7280;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main span {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #6B7280;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main i.is-processing {
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-status,
|
||||
.mnote-weknora-kb-type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
background: #ECFDF5;
|
||||
color: #047857;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-create-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #D1D5DB;
|
||||
border-radius: 8px;
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-create-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 280px);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 16px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-hero h2 {
|
||||
margin: 8px 0 4px;
|
||||
color: #111827;
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-breadcrumb strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-hero p {
|
||||
margin: 0;
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-toolbar,
|
||||
.mnote-weknora-doc-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 260px;
|
||||
padding: 12px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar-head strong {
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar-head span {
|
||||
color: #9CA3AF;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags,
|
||||
.mnote-weknora-doc-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags button,
|
||||
.mnote-weknora-doc-tags span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 28px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 7px;
|
||||
background: #FFFFFF;
|
||||
color: #4B5563;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
padding: 0 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags button[data-active="true"] {
|
||||
border-color: #A7D8B8;
|
||||
background: #F0F9F4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mnote-weknora-view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.mnote-weknora-view-switch button {
|
||||
width: 28px;
|
||||
min-height: 26px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6B7280;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mnote-weknora-view-switch button.is-active {
|
||||
background: #EAF7EA;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-search {
|
||||
display: flex;
|
||||
flex: 1 1 260px;
|
||||
min-width: 220px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-search input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table[data-weknora-document-view="grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table::before {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px 104px 124px;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
color: #6B7280;
|
||||
content: "文档 状态 Chunk 操作";
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table[data-weknora-document-view="grid"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mnote-weknora-placeholder-panel {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.mnote-weknora-placeholder-panel strong {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.mnote-weknora-placeholder-panel span {
|
||||
max-width: 560px;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-meta {
|
||||
@@ -2663,6 +3190,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-main .mnote-weknora-source-aux {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mnote-weknora-source-aux i {
|
||||
display: inline-flex;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #F3F4F6;
|
||||
color: #4B5563;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
padding: 0 7px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -2747,5 +3295,3 @@
|
||||
color: #1B1C1C;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2221,10 +2221,71 @@ button.wolai-page-ai-history-main span {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[data-page-ai-openhub-host="true"] .wolai-page-ai-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[data-page-ai-openhub-host="true"] .wolai-page-ai-resize-handle {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics {
|
||||
flex: 0 0 auto;
|
||||
max-height: 32px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
|
||||
background: rgba(247, 247, 245, 0.78);
|
||||
color: rgba(27, 28, 28, 0.58);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics summary {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics:not([open]) > :not(summary) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics[open] {
|
||||
max-height: min(36vh, 220px);
|
||||
overflow: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics[open] summary {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics .wolai-page-ai-opencode-row,
|
||||
.wolai-page-ai-openhub-diagnostics .wolai-page-ai-opencode-empty,
|
||||
.wolai-page-ai-openhub-diagnostics .wolai-page-ai-opencode-badges {
|
||||
margin-right: 12px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chrome {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
* - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve
|
||||
* - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode
|
||||
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
|
||||
* - ENABLE_OPENHUB:设为 "1" or "true" 时启用 OpenHub FastAPI
|
||||
* - OPENHUB_CMD / OPENHUB_BACKEND_CMD:覆盖 OpenHub FastAPI 启动命令;设置后即视为显式启用 OpenHub
|
||||
* - OPENHUB_PORT / OPENHUB_BACKEND_PORT:OpenHub FastAPI 端口,默认 18080
|
||||
* - SKIP_OPENHUB:设为 "1" or "true" 可强制跳过 OpenHub
|
||||
* - OPENHUB_REDIS_URL / OPENHUB_REDIS_DB:记录 OpenHub Redis 位置;OPENHUB_REDIS_HEALTH_URL 可选做 HTTP health 检查
|
||||
* - OPENHUB_REDIS_HEALTH_URL:可选 Redis health URL;dev-hot 只检查,不释放或结束 OpenHub 相关端口
|
||||
* - OPENHUB_OPENCODE_BASE_URL:OpenHub 侧 opencode serve base URL;默认复用 MNOTE_OPENCODE_BASE_URL
|
||||
* - SKIP_OPENHUB_HEALTH:设为 "1" or "true" 可跳过 OpenHub/Redis/opencode health 预检
|
||||
* - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链
|
||||
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
||||
*/
|
||||
|
||||
@@ -49,6 +58,7 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
|
||||
const openhubPortFromEnv = Number(process.env.OPENHUB_BACKEND_PORT || process.env.OPENHUB_PORT || 18080);
|
||||
|
||||
function hasCommand(command) {
|
||||
try {
|
||||
@@ -76,6 +86,11 @@ function buildDefaultOpencodeCommand(port) {
|
||||
return `while true; do script -qfec "opencode serve --hostname=127.0.0.1 --port ${port} --print-logs" /dev/null; sleep 1; done`;
|
||||
}
|
||||
|
||||
function buildDefaultOpenHubCommand(port) {
|
||||
const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend";
|
||||
return `cd ${JSON.stringify(openhubBackendDir)} && uvicorn app.main:app --host 127.0.0.1 --port ${port}`;
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
@@ -93,6 +108,54 @@ function shouldStartOpencode(env = process.env) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldStartOpenHub(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENHUB)) return false;
|
||||
if (String(env.OPENHUB_BACKEND_CMD || env.OPENHUB_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_OPENHUB);
|
||||
}
|
||||
|
||||
function shouldCheckOpenHubHealth(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENHUB_HEALTH)) return false;
|
||||
return shouldStartOpenHub(env) || isEnabledEnv(env.CHECK_OPENHUB_HEALTH);
|
||||
}
|
||||
|
||||
function resolveOpenHubHealthPlan(env = process.env) {
|
||||
const openhubPort = Number(env.OPENHUB_BACKEND_PORT || env.OPENHUB_PORT || openhubPortFromEnv);
|
||||
const opencodePort = Number(env.OPENCODE_PORT || opencodePortFromEnv);
|
||||
const baseUrl = String(env.MNOTE_OPENHUB_BASE_URL || env.OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).replace(/\/+$/, "");
|
||||
const redisHealthUrl = String(env.OPENHUB_REDIS_HEALTH_URL || "").trim();
|
||||
const redisUrl = String(env.OPENHUB_REDIS_URL || "").trim();
|
||||
const redisDb = String(env.OPENHUB_REDIS_DB || "").trim();
|
||||
const opencodeBaseUrl = String(env.OPENHUB_OPENCODE_BASE_URL || env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).replace(/\/+$/, "");
|
||||
const requireHealth = isEnabledEnv(env.REQUIRE_OPENHUB_HEALTH);
|
||||
return {
|
||||
enabled: shouldCheckOpenHubHealth(env),
|
||||
openhub: {
|
||||
label: "OpenHub FastAPI",
|
||||
url: env.OPENHUB_HEALTH_URL || `${baseUrl}/health`,
|
||||
required: requireHealth,
|
||||
},
|
||||
redis: {
|
||||
label: "OpenHub Redis",
|
||||
url: redisHealthUrl,
|
||||
redisUrl,
|
||||
redisDb,
|
||||
required: isEnabledEnv(env.REQUIRE_OPENHUB_REDIS_HEALTH),
|
||||
skipped: !redisHealthUrl,
|
||||
},
|
||||
opencode: {
|
||||
label: "opencode",
|
||||
url: env.OPENCODE_HEALTH_URL || `${opencodeBaseUrl}/global/health`,
|
||||
required: requireHealth,
|
||||
},
|
||||
gitSnapshotRestore: {
|
||||
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
||||
value: String(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||
defaultDisabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRuntimePlan(env = process.env) {
|
||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||
const skipGateway = false;
|
||||
@@ -107,6 +170,8 @@ function resolveRuntimePlan(env = process.env) {
|
||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
|
||||
MNOTE_OPENHUB_BASE_URL: env.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPortFromEnv}`,
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,6 +213,18 @@ const tasks = [
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(shouldStartOpenHub(process.env)
|
||||
? [
|
||||
{
|
||||
name: "openhub",
|
||||
command:
|
||||
process.env.OPENHUB_BACKEND_CMD ||
|
||||
process.env.OPENHUB_CMD ||
|
||||
buildDefaultOpenHubCommand(openhubPortFromEnv),
|
||||
cwd: rootDir,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function findTask(name) {
|
||||
@@ -363,6 +440,50 @@ async function ensurePortFree(port, nameForLog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkHttpHealth(url, label, required) {
|
||||
if (!url) {
|
||||
logPrefix("openhub-health", `${label} health 未配置,已跳过。`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1200);
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { accept: "application/json,text/plain,*/*" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (response.ok) {
|
||||
logPrefix("openhub-health", `${label} 可达:${url}`);
|
||||
return true;
|
||||
}
|
||||
logPrefix("openhub-health", `${label} 返回 HTTP ${response.status}:${url}`);
|
||||
return !required;
|
||||
} catch (error) {
|
||||
logPrefix("openhub-health", `${label} 不可达:${url} (${error.message})`);
|
||||
return !required;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOpenHubHealth(plan = resolveOpenHubHealthPlan(process.env)) {
|
||||
if (!plan.enabled) {
|
||||
return true;
|
||||
}
|
||||
logPrefix(
|
||||
"openhub-health",
|
||||
`Git snapshot/restore 默认关闭:${plan.gitSnapshotRestore.env}=${plan.gitSnapshotRestore.value || "1"}`,
|
||||
);
|
||||
const checks = [
|
||||
await checkHttpHealth(plan.openhub.url, plan.openhub.label, plan.openhub.required),
|
||||
plan.redis.skipped
|
||||
? (logPrefix("openhub-health", "OpenHub Redis health 未配置,已跳过非破坏性检查。"), true)
|
||||
: await checkHttpHealth(plan.redis.url, plan.redis.label, plan.redis.required),
|
||||
await checkHttpHealth(plan.opencode.url, plan.opencode.label, plan.opencode.required),
|
||||
];
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
@@ -519,6 +640,22 @@ async function main() {
|
||||
|
||||
}
|
||||
|
||||
if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) {
|
||||
const openhubTask = findTask("openhub");
|
||||
if (!openhubTask) {
|
||||
throw new Error("缺少 OpenHub 任务配置");
|
||||
}
|
||||
openhubTask.command = buildDefaultOpenHubCommand(openhubPortFromEnv);
|
||||
} else if (isEnabledEnv(process.env.SKIP_OPENHUB)) {
|
||||
logPrefix("openhub", "已跳过 OpenHub FastAPI(SKIP_OPENHUB=1)。");
|
||||
}
|
||||
|
||||
const healthOk = await checkOpenHubHealth(resolveOpenHubHealthPlan(process.env));
|
||||
if (!healthOk) {
|
||||
console.error("OpenHub health 预检失败,已中止启动。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||
process.exit(1);
|
||||
@@ -537,12 +674,16 @@ if (require.main === module) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDefaultOpenHubCommand,
|
||||
checkOpenHubHealth,
|
||||
ensurePortFree,
|
||||
getListeningPidsByPort,
|
||||
getProcessNameByPid,
|
||||
isPortFree,
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
@@ -3,11 +3,14 @@ const { spawn } = require("node:child_process");
|
||||
const net = require("node:net");
|
||||
const { test } = require("node:test");
|
||||
const {
|
||||
buildDefaultOpenHubCommand,
|
||||
resolveBackendExecutable,
|
||||
ensurePortFree,
|
||||
isPortFree,
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
} = require("./desktop-hot.js");
|
||||
|
||||
function findFreePort() {
|
||||
@@ -127,6 +130,8 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
||||
MNOTE_OPENHUB_BASE_URL: "http://127.0.0.1:18080",
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: "1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,3 +142,40 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartBackend({ BACKEND_CMD: "custom-backend" }), true);
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
|
||||
});
|
||||
|
||||
test("OpenHub FastAPI 默认跳过,显式开启或命令覆盖时才启动", () => {
|
||||
assert.equal(shouldStartOpenHub({}), false);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1" }), true);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "true" }), true);
|
||||
assert.equal(shouldStartOpenHub({ OPENHUB_CMD: "custom-openhub" }), true);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1", SKIP_OPENHUB: "1" }), false);
|
||||
});
|
||||
|
||||
test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git snapshot/restore", () => {
|
||||
const plan = resolveOpenHubHealthPlan({
|
||||
ENABLE_OPENHUB: "1",
|
||||
REQUIRE_OPENHUB_HEALTH: "1",
|
||||
OPENHUB_PORT: "18081",
|
||||
OPENCODE_PORT: "4097",
|
||||
OPENHUB_REDIS_HEALTH_URL: "http://127.0.0.1:6379/health",
|
||||
});
|
||||
|
||||
assert.equal(plan.enabled, true);
|
||||
assert.equal(plan.openhub.url, "http://127.0.0.1:18081/health");
|
||||
assert.equal(plan.openhub.required, true);
|
||||
assert.equal(plan.redis.url, "http://127.0.0.1:6379/health");
|
||||
assert.equal(plan.redis.skipped, false);
|
||||
assert.equal(plan.opencode.url, "http://127.0.0.1:4097/global/health");
|
||||
assert.deepEqual(plan.gitSnapshotRestore, {
|
||||
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
||||
value: "1",
|
||||
defaultDisabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenHub 默认命令只启动 FastAPI,不包含 snapshot/restore 写操作", () => {
|
||||
const command = buildDefaultOpenHubCommand(18082);
|
||||
assert.match(command, /uvicorn app\.main:app/);
|
||||
assert.match(command, /--port 18082/);
|
||||
assert.doesNotMatch(command, /snapshot|restore|revert|kill-port/i);
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ function ensureOwnerPage() {
|
||||
if (!fs.existsSync(ownerPath)) {
|
||||
fs.writeFileSync(
|
||||
ownerPath,
|
||||
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for LightRAG retrieval.", ""].join("\n"),
|
||||
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for WeKnora retrieval.", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@ async function main() {
|
||||
});
|
||||
assert(knowledgeSearch.ok(), `资料库短 query 检索失败: ${knowledgeSearch.status()} ${await knowledgeSearch.text()}`);
|
||||
const knowledgePayload = await knowledgeSearch.json();
|
||||
assert((knowledgePayload.results?.length || 0) >= 8, `资料库 2 字 query 应返回段落去重后的 LightRAG 检索命中: ${JSON.stringify(knowledgePayload, null, 2).slice(0, 2000)}`);
|
||||
assert((knowledgePayload.results?.length || 0) >= 8, `资料库 2 字 query 应返回段落去重后的 WeKnora 检索命中: ${JSON.stringify(knowledgePayload, null, 2).slice(0, 2000)}`);
|
||||
assert(String(knowledgePayload.results?.[0]?.snippet || "").includes(SHORT_QUERY), `资料库结果未包含 ${SHORT_QUERY}: ${JSON.stringify(knowledgePayload.results?.[0], null, 2)}`);
|
||||
assert(knowledgePayload.references?.some((reference) => reference.matchSource === "lightrag_search"), `2 字 query 应走 LightRAG search provider: ${JSON.stringify(knowledgePayload.references?.slice(0, 3), null, 2)}`);
|
||||
assert(knowledgePayload.references?.some((reference) => reference.provider === "weknora" || reference.matchSource === "weknora_search"), `2 字 query 应走 WeKnora search provider: ${JSON.stringify(knowledgePayload.references?.slice(0, 3), null, 2)}`);
|
||||
const firstKnowledgeResult = knowledgePayload.results?.[0] || {};
|
||||
const firstKnowledgeCitation = knowledgePayload.citations?.[0] || {};
|
||||
assert(firstKnowledgeResult.displayQuote && firstKnowledgeResult.locatorEvidenceText, `搜索结果缺少 displayQuote/locatorEvidenceText: ${JSON.stringify(firstKnowledgeResult, null, 2)}`);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task544-weknora-provider-bridge-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function assertNotContains(content, needle, label) {
|
||||
assert(!content.includes(needle), `${label} 不应包含: ${needle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const route = read("rust/crates/mnote-web/src/routes/knowledge_rag.rs");
|
||||
const hermesKnowledge = read("rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs");
|
||||
const manifest = read("rust/crates/mnote-web/src/hermes_tools/manifest.rs");
|
||||
const toolRoute = read("rust/crates/mnote-web/src/routes/hermes_tools.rs");
|
||||
const ui = read("rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js");
|
||||
|
||||
assertContains(route, 'const DEFAULT_KNOWLEDGE_PROVIDER: &str = "weknora";', "provider boundary");
|
||||
assertContains(route, 'const LEGACY_KNOWLEDGE_PROVIDER: &str = "lightrag_legacy";', "legacy provider boundary");
|
||||
assertContains(route, '"/knowledge-search"', "WeKnora endpoint");
|
||||
assertContains(route, '"knowledge_base_ids"', "WeKnora request shape");
|
||||
assertContains(route, '"knowledge_ids"', "WeKnora request shape");
|
||||
assertContains(route, '"match_count"', "WeKnora request shape");
|
||||
assertContains(route, '"providerConfig": knowledge_provider_config()', "status provider config");
|
||||
assertContains(route, '"health": weknora_health(&context).await', "WeKnora health");
|
||||
assertContains(route, '"registry": registry', "status registry");
|
||||
assertContains(route, '"providerIds"', "WeKnora reference mapping");
|
||||
assertContains(route, '"providerFilenameIsNotLocalPath": true', "WeKnora locator diagnostics");
|
||||
assertContains(route, '"locatorDegraded": locator_degraded', "WeKnora degraded locator");
|
||||
assertContains(route, '"reference": chunk', "WeKnora raw reference retention");
|
||||
assertContains(route, 'Value::Null', "WeKnora filePath null guard");
|
||||
assertContains(route, '"chunkMetadata"', "WeKnora metadata retention");
|
||||
assertContains(route, '"imageInfo"', "WeKnora image info retention");
|
||||
assertContains(route, '"parentChunk"', "WeKnora parent chunk retention");
|
||||
assertContains(route, '"subChunks"', "WeKnora sub chunk retention");
|
||||
assertContains(route, "ingest_weknora_registry_only", "WeKnora ingest branch");
|
||||
assertContains(route, "upload_weknora_file", "WeKnora provider upload");
|
||||
assertContains(route, '/knowledge-bases/{}/knowledge/file', "WeKnora file ingest endpoint");
|
||||
assertContains(route, 'reqwest::multipart::Form::new()', "WeKnora multipart upload");
|
||||
assertContains(route, '"fileName"', "WeKnora preserves root relative path");
|
||||
assertContains(route, '"mnote_source_root_relative_path"', "WeKnora source metadata");
|
||||
assertContains(route, '"scanSkipped": false', "WeKnora ingest uses provider upload");
|
||||
assertContains(route, '"scanSkipReason": "weknora_provider_direct_upload"', "WeKnora ingest no LightRAG scan");
|
||||
assertContains(route, '"pending_provider_ingest_missing_kb"', "WeKnora ingest missing kb pending");
|
||||
assertContains(route, '"pending_provider_ingest"', "WeKnora ingest provider pending");
|
||||
assertContains(route, '"registry_only"', "WeKnora ingest registry mapping");
|
||||
assertContains(route, '"provider_mapped"', "WeKnora provider id mapping");
|
||||
assertContains(route, "delete_weknora_registry_source", "WeKnora delete branch");
|
||||
assertContains(route, '"providerDeleteAttempted": false', "WeKnora delete no provider call");
|
||||
assertContains(route, '"localFileDeleted": false', "WeKnora delete keeps local file");
|
||||
assertContains(route, '"pending_provider_delete"', "WeKnora delete pending provider marker");
|
||||
assertContains(route, "weknora_section_context_payload", "WeKnora section-context branch");
|
||||
assertContains(route, '"sidecarRead": false', "WeKnora section-context no LightRAG sidecar");
|
||||
assertContains(route, '"degradedReason": "weknora_local_source_context_without_provider_chunks"', "WeKnora section-context degraded local fallback");
|
||||
assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return ingest_weknora_registry_only', "WeKnora ingest bypasses LightRAG staging");
|
||||
assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return delete_weknora_registry_source', "WeKnora delete bypasses LightRAG delete");
|
||||
assertContains(route, "let payload = weknora_section_context_payload", "WeKnora section-context bypasses sidecar");
|
||||
|
||||
for (const toolName of [
|
||||
"mnote.weknora.search",
|
||||
"mnote.weknora.list_sources",
|
||||
"mnote.weknora.get_source_status",
|
||||
"mnote.weknora.open_reference",
|
||||
]) {
|
||||
assertContains(manifest, `"${toolName}"`, "manifest");
|
||||
assertContains(toolRoute, `"${toolName}"`, "tool dispatch");
|
||||
}
|
||||
assertContains(manifest, '"scope"', "WeKnora tool scope field");
|
||||
assertContains(manifest, '"allowlist"', "WeKnora tool allowlist field");
|
||||
assertContains(manifest, '"allowedRoots"', "WeKnora tool allowedRoots field");
|
||||
assertContains(manifest, '"aiAccessScope"', "WeKnora tool aiAccessScope field");
|
||||
assertContains(manifest, '"sourcePaths"', "WeKnora tool sourcePaths field");
|
||||
assertContains(hermesKnowledge, "mnote_weknora_scope_required", "WeKnora tool scope guard");
|
||||
|
||||
assertNotContains(ui, "打开 LightRAG", "UI default provider copy");
|
||||
assertNotContains(ui, "正在读取 LightRAG 状态", "UI default provider copy");
|
||||
assertNotContains(ui, "无法连接 LightRAG 服务", "UI default provider copy");
|
||||
assertContains(ui, "WeKnora", "UI provider label");
|
||||
assertContains(ui, "知识库 provider", "UI neutral copy");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [
|
||||
"rust/crates/mnote-web/src/routes/knowledge_rag.rs",
|
||||
"rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs",
|
||||
"rust/crates/mnote-web/src/hermes_tools/manifest.rs",
|
||||
"rust/crates/mnote-web/src/routes/hermes_tools.rs",
|
||||
"rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js",
|
||||
],
|
||||
provider: "weknora",
|
||||
assertions: {
|
||||
providerBoundary: true,
|
||||
providerIds: true,
|
||||
locatorDegraded: true,
|
||||
manifestFacade: true,
|
||||
scopeFields: true,
|
||||
uiNoDefaultLightRagCopy: true,
|
||||
ingestNoLightRagScan: true,
|
||||
deleteNoLightRagDelete: true,
|
||||
sectionContextNoSidecar: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const runtimePath = path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js');
|
||||
const routePath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_openhub.rs');
|
||||
const routesModPath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/mod.rs');
|
||||
|
||||
const runtime = fs.readFileSync(runtimePath, 'utf8');
|
||||
const route = fs.readFileSync(routePath, 'utf8');
|
||||
const routesMod = fs.readFileSync(routesModPath, 'utf8');
|
||||
|
||||
const checks = [
|
||||
['openhub host enabled by default', runtime.includes('function pageAiOpenHubHostEnabled()') && runtime.includes('return true;') && runtime.includes('data-page-ai-openhub-host')],
|
||||
['openhub bootstrap api', runtime.includes('/api/page-ai/openhub/bootstrap') && routesMod.includes('/api/page-ai/openhub/bootstrap')],
|
||||
['mnote auth truth copy', route.includes('"authTruth": "mnote_session"') && runtime.includes('拒绝 OpenHub JWT/localStorage')],
|
||||
['openhub user key derived', route.includes('"openhubUserKey"') && route.includes('stable_hash("openhub_user"')],
|
||||
['workspace session tool scope', route.includes('"openhubSessionScope"') && route.includes('"skillScope"') && route.includes('"mcpScope"') && route.includes('"toolPermissionScope"')],
|
||||
['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')],
|
||||
['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')],
|
||||
['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')],
|
||||
['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')],
|
||||
['layered status endpoint', route.includes('"openhub_fastapi"') && route.includes('"opencode"') && route.includes('"weknora"') && route.includes('"mnote_binding"')],
|
||||
['degraded external services are explicit', route.includes('"degraded"') && route.includes('reachable') && route.includes('upstream_http_')],
|
||||
['ai proxy boundary', route.includes('pub async fn ai_proxy') && routesMod.includes('/page-ai/openhub/ai/{*path}')],
|
||||
['no visible opencode fallback action', !runtime.includes('openhub-use-opencode-fallback')],
|
||||
['non ai route guard', route.includes('page_ai_openhub_non_ai_route_guarded') && routesMod.includes('/page-ai/openhub/knowledge/{*path}') && routesMod.includes('/page-ai/openhub/file/{*path}') && routesMod.includes('/page-ai/openhub/git/{*path}')],
|
||||
['static ai shell', route.includes('data-mnote-openhub-ai-shell="static-boundary"') && routesMod.includes('/page-ai/openhub/ai')],
|
||||
['openhub quick address actions are native openhub source not proxy overlay', !route.includes('data-mnote-openhub-ai-quick-actions') && !route.includes('data-mnote-openhub-send-current-tab') && !route.includes('data-mnote-openhub-send-current-folder')],
|
||||
['openhub quick address bridge uses active tab only', runtime.includes("message.source === 'openhub-ai'") && runtime.includes("message.type === 'mnote:get-active-tab-address'") && runtime.includes('pageAiCurrentActiveTabEditorTarget') && runtime.includes("type: 'mnote:active-tab-address'")],
|
||||
['openhub folder action sends folder address only', runtime.includes('pageAiCurrentActiveTabAddressPayload') && runtime.includes('folderUrl') && runtime.includes("kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl")],
|
||||
['openhub diagnostics are hidden from user chrome', runtime.includes('wolai-page-ai-openhub-diagnostics') && runtime.includes('data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true"')],
|
||||
['no duplicate mnote openhub shell header', !runtime.includes('<h2 class="wolai-page-ai-title">OpenHub AI</h2>') && !runtime.includes('data-page-ai-openhub-runtime-status>OpenHub host boundary 静态占位')],
|
||||
['no visible openhub legacy fallback switch', !runtime.includes("localStorage.setItem('mnote.page_ai.openhub_host', '0')")],
|
||||
['openhub fallback payload has no legacy route', route.includes('"enabled": false') && !route.includes('"legacyRoute": "/page-ai/opencode"')],
|
||||
];
|
||||
|
||||
const failed = checks.filter(([, ok]) => !ok);
|
||||
if (failed.length) {
|
||||
console.error('Page AI OpenHub host static smoke failed:');
|
||||
for (const [name] of failed) console.error(`- ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Page AI OpenHub host static smoke passed.');
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task769-weknora-knowledge-settings-ui-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const UI_PATH = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function assertNotContains(content, needle, label) {
|
||||
assert(!content.includes(needle), `${label} 不应包含: ${needle}`);
|
||||
}
|
||||
|
||||
function assertOrdered(content, first, second, label) {
|
||||
const firstIndex = content.indexOf(first);
|
||||
const secondIndex = content.indexOf(second);
|
||||
assert(firstIndex >= 0, `${label} 缺少: ${first}`);
|
||||
assert(secondIndex >= 0, `${label} 缺少: ${second}`);
|
||||
assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应优先于 ${second}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const ui = read(UI_PATH);
|
||||
|
||||
for (const forbidden of [
|
||||
"打开 LightRAG",
|
||||
"相似度阈值",
|
||||
"相关度:",
|
||||
"本地 (Local)",
|
||||
"全局 (Global)",
|
||||
"朴素 (Naive)",
|
||||
"混合模式 (Mix)",
|
||||
"正在读取 LightRAG 状态",
|
||||
"无法连接 LightRAG 服务",
|
||||
]) {
|
||||
assertNotContains(ui, forbidden, "WeKnora knowledge settings UI");
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"WeKnora",
|
||||
"WeKnora hybrid",
|
||||
"data-testid=\"mnote-weknora-knowledge-settings-panel\"",
|
||||
"data-knowledge-rag-default-provider=\"weknora\"",
|
||||
"data-knowledge-rag-legacy-fallback-provider=\"lightrag\"",
|
||||
"data-knowledge-rag-source-registry=\"mnote\"",
|
||||
"data-knowledge-rag-provider-index=\"weknora-kb-chunk-index\"",
|
||||
"data-knowledge-rag-active-provider",
|
||||
"data-knowledge-rag-reference-boundary",
|
||||
"data-knowledge-rag-provider=\"weknora\"",
|
||||
"data-knowledge-rag-provider-id",
|
||||
"data-knowledge-rag-provider-ids",
|
||||
"data-knowledge-rag-knowledge-id",
|
||||
"data-knowledge-rag-chunk-count",
|
||||
"data-knowledge-rag-processing-status",
|
||||
"data-knowledge-rag-open-reference-mode",
|
||||
"data-knowledge-rag-delete-local-file-policy=\"preserve-local-file\"",
|
||||
"data-kb-rag-score-label=\"provider-ranking-score\"",
|
||||
"data-kb-rag-score-is-similarity-percent=\"false\"",
|
||||
"provider 排序分",
|
||||
"MNote source registry",
|
||||
"WeKnora KB chunk index",
|
||||
"provider=weknora",
|
||||
"provider ids",
|
||||
"knowledgeId",
|
||||
"chunkCount",
|
||||
"processingStatus",
|
||||
"定位降级",
|
||||
"删除 provider index 与 registry 映射,不删除本地文件",
|
||||
"打开知识库",
|
||||
"Provider 检索策略",
|
||||
"兼容阈值字段保留在请求层,不作为默认主控",
|
||||
]) {
|
||||
assertContains(ui, required, "WeKnora knowledge settings UI");
|
||||
}
|
||||
|
||||
assertOrdered(
|
||||
ui,
|
||||
"(weknora && weknora.dashboardUrl)",
|
||||
"status && (status.dashboardUrl || status.endpoint)",
|
||||
"dashboardUrl 优先级"
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [UI_PATH],
|
||||
assertions: {
|
||||
noDefaultLightRagCopy: true,
|
||||
noSimilarityThresholdPrimaryControl: true,
|
||||
noRelevancePercentCopy: true,
|
||||
weknoraHybridSearchCopy: true,
|
||||
providerSelectorCoverage: true,
|
||||
sourceRegistrySelectorCoverage: true,
|
||||
processingSelectorCoverage: true,
|
||||
citationOpenReferenceSelectorCoverage: true,
|
||||
providerRankingScoreCopy: true,
|
||||
sourceRegistryBoundaryCopy: true,
|
||||
deleteIndexDoesNotDeleteLocalFileCopy: true,
|
||||
dashboardUrlPriority: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const openHubRoot =
|
||||
process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/OpenHub';
|
||||
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
|
||||
const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
|
||||
const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function assertCheck(name, passed) {
|
||||
if (!passed) failures.push(name);
|
||||
}
|
||||
|
||||
function includesAll(source, needles) {
|
||||
return needles.every((needle) => source.includes(needle));
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
const gitSnapshot = read('app/services/git_snapshot.py');
|
||||
const stream = read('app/services/stream.py');
|
||||
const taskExecutor = read('app/services/task_executor.py');
|
||||
const session = read('app/api/session.py');
|
||||
|
||||
assertCheck(
|
||||
'git_snapshot exposes MNote disable env and legacy equivalent',
|
||||
includesAll(gitSnapshot, [disableEnv, legacyDisableEnv, 'def is_snapshot_restore_disabled'])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'git_snapshot low-level git write command guard covers destructive/write commands',
|
||||
includesAll(gitSnapshot, [
|
||||
'_GIT_WRITE_COMMANDS',
|
||||
'"init"',
|
||||
'"config"',
|
||||
'"add"',
|
||||
'"commit"',
|
||||
'"checkout"',
|
||||
'"restore"',
|
||||
'"reset"',
|
||||
'"revert"',
|
||||
'is_snapshot_restore_disabled() and _is_git_write(args)',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'git_snapshot high-level write APIs short-circuit when disabled',
|
||||
includesAll(gitSnapshot, [
|
||||
'def init_git_repo',
|
||||
'def create_snapshot',
|
||||
'def create_restore_snapshot',
|
||||
'def restore_all',
|
||||
'def restore_single_file',
|
||||
'disabled by {GIT_SNAPSHOT_RESTORE_DISABLE_ENV}',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'stream automatic snapshot path is guarded before init/create_snapshot',
|
||||
stream.includes('not git_snap.is_snapshot_restore_disabled()') &&
|
||||
stream.includes('git_snap.init_git_repo') &&
|
||||
stream.includes('git_snap.create_snapshot') &&
|
||||
stream.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'task_executor automatic snapshot path is guarded before init/create_snapshot',
|
||||
taskExecutor.includes('not git_snapshot.is_snapshot_restore_disabled()') &&
|
||||
taskExecutor.includes('git_snapshot.init_git_repo') &&
|
||||
taskExecutor.includes('git_snapshot.create_snapshot') &&
|
||||
taskExecutor.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
|
||||
);
|
||||
|
||||
const restoreRouteGuardCount = (
|
||||
session.match(/git_snapshot\.is_snapshot_restore_disabled\(\)/g) || []
|
||||
).length;
|
||||
assertCheck(
|
||||
'session restore routes reject when disabled',
|
||||
restoreRouteGuardCount >= 2 &&
|
||||
session.includes('Git snapshot/restore 写链已由 MNote 禁用') &&
|
||||
session.includes('git_snapshot.restore_all') &&
|
||||
session.includes('git_snapshot.restore_single_file') &&
|
||||
session.includes('git_snapshot.create_restore_snapshot')
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error('OpenHub git snapshot/restore guard static smoke failed:');
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('OpenHub git snapshot/restore guard static smoke passed.');
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const openHubRoot =
|
||||
process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/OpenHub';
|
||||
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function assertCheck(name, passed) {
|
||||
if (!passed) failures.push(name);
|
||||
}
|
||||
|
||||
function includesAll(source, needles) {
|
||||
return needles.every((needle) => source.includes(needle));
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
const mnoteScope = read('app/core/mnote_scope.py');
|
||||
const auth = read('app/core/auth.py');
|
||||
const query = read('app/api/query.py');
|
||||
const session = read('app/api/session.py');
|
||||
const stream = read('app/services/stream.py');
|
||||
|
||||
const requiredHeaders = [
|
||||
'X-MNote-User-Key',
|
||||
'X-MNote-Workspace-Key',
|
||||
'X-MNote-Session-Scope',
|
||||
'X-MNote-Root-Uri',
|
||||
'X-MNote-Page-Resource-Id',
|
||||
'X-MNote-Tool-Permission-Scope',
|
||||
'X-MNote-WeKnora-Tool-Scope',
|
||||
];
|
||||
|
||||
assertCheck(
|
||||
'mnote scope helper exists with all controlled headers',
|
||||
includesAll(mnoteScope, [
|
||||
'MNOTE_HOST_TRUTH = "mnote_controlled_headers"',
|
||||
'def derive_mnote_user',
|
||||
'def resolve_user_workspace',
|
||||
'def get_mnote_scope_metadata',
|
||||
...requiredHeaders,
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'derived user is stable and not a shared singleton',
|
||||
includesAll(mnoteScope, [
|
||||
'def _stable_openhub_user_id',
|
||||
'mnote_openhub_user_id',
|
||||
'user_key',
|
||||
'workspace_key',
|
||||
'"openhub_user_id"',
|
||||
'"openhub_username"',
|
||||
]) && !mnoteScope.includes('mnote_shared_user')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'session and workspace scope are derived from MNote scope',
|
||||
includesAll(mnoteScope, [
|
||||
'def _stable_openhub_session_id',
|
||||
'mnote_openhub_session',
|
||||
'openhub_workspace_scope',
|
||||
'session_scope',
|
||||
'root_uri',
|
||||
'workspace_path',
|
||||
'_root_uri_to_workspace_path',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'tool and weknora scopes are retained as MNote scope metadata',
|
||||
includesAll(mnoteScope, [
|
||||
'tool_permission_scope',
|
||||
'weknora_tool_scope',
|
||||
'_parse_scope_header',
|
||||
'"mnote_scope"',
|
||||
'"source_headers"',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'MNote host mode rejects frontend JWT/localStorage truth',
|
||||
includesAll(mnoteScope, [
|
||||
'REJECTED_MNOTE_HOST_TRUTHS',
|
||||
'localStorage',
|
||||
'OpenHub JWT',
|
||||
'frontend JWT',
|
||||
]) &&
|
||||
auth.includes('derive_mnote_user(request)') &&
|
||||
auth.includes('HTTPBearer(auto_error=False)') &&
|
||||
auth.indexOf('derive_mnote_user(request)') < auth.indexOf('validate_token(token)')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'query/session entries consume derived workspace and scope',
|
||||
query.includes('resolve_user_workspace(current_user)') &&
|
||||
query.includes('not current_user.get("mnote_host_mode")') &&
|
||||
query.includes('mnote_scope=get_mnote_scope_metadata(current_user)') &&
|
||||
session.includes('resolve_user_workspace(current_user)') &&
|
||||
session.includes('mnote_scope=get_mnote_scope_metadata(current_user)')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'stream persists MNote scope metadata with user message',
|
||||
includesAll(stream, [
|
||||
'mnote_scope: Optional[dict] = None',
|
||||
'metadata["mnote_scope"] = mnote_scope',
|
||||
'database.save_session',
|
||||
'user_id',
|
||||
'workspace_path',
|
||||
'not mnote_scope',
|
||||
])
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error('OpenHub MNote scope bridge static smoke failed:');
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('OpenHub MNote scope bridge static smoke passed.');
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const FIXTURE_DIR = process.env.MNOTE_TASK772_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SKIP_PATH = path.join(OUTPUT_DIR, "skip.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK772_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK772_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function envList(name) {
|
||||
return String(process.env[name] || "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function configuredKnowledgeBaseIds() {
|
||||
return envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS").concat(envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_ID"));
|
||||
}
|
||||
|
||||
function hasWeKnoraApiKey() {
|
||||
return Boolean(String(process.env.MNOTE_WEKNORA_API_KEY || process.env.WEKNORA_API_KEY || "").trim());
|
||||
}
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function skip(reason, details = {}) {
|
||||
const payload = {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
...details,
|
||||
};
|
||||
writeJson(SKIP_PATH, payload);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_status", `knowledge-rag status 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function assertWeKnoraStatus(statusPayload, expectedKbIds) {
|
||||
const activeProvider = statusPayload?.providerConfig?.active || statusPayload?.provider;
|
||||
assert.equal(activeProvider, "weknora", `MNote knowledge provider 不是 weknora: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
|
||||
const serverKbIds = statusPayload?.providerConfig?.weknora?.knowledgeBaseIds || [];
|
||||
assert(serverKbIds.length > 0, `MNote 3000 未加载 WeKnora KB id: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
|
||||
for (const kbId of expectedKbIds) {
|
||||
assert(serverKbIds.includes(kbId), `MNote 3000 未加载当前 WeKnora KB id ${kbId}: ${JSON.stringify(serverKbIds)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_http", `knowledge-rag ingest HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", `ingest provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
if (payload?.ok !== true || payload?.retryRequired) {
|
||||
throw failLayer("weknora_ingest_provider", "WeKnora ingest 未成功完成 provider upload", {
|
||||
response: payload,
|
||||
});
|
||||
}
|
||||
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
|
||||
const item = configured.find((source) => source.sourceRootRelativePath === sourcePath);
|
||||
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(configured, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(item.provider, "weknora", `ingest source provider 应为 weknora: ${JSON.stringify(item, null, 2)}`);
|
||||
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应写回 provider_mapped: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.upload?.attempted === true, `ingest 应真实调用 WeKnora upload: ${JSON.stringify(item, null, 2)}`);
|
||||
return { payload, item };
|
||||
}
|
||||
|
||||
async function search(context, query, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [sourcePath],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_search_http", `knowledge-rag search HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function referenceProviderIds(reference) {
|
||||
return {
|
||||
knowledgeBaseId: reference?.providerIds?.knowledgeBaseId || reference?.providerKnowledgeBaseId || "",
|
||||
knowledgeId: reference?.providerIds?.knowledgeId || reference?.providerKnowledgeId || "",
|
||||
chunkId: reference?.providerIds?.chunkId || reference?.providerChunkId || reference?.chunkId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function findTargetReference(searchPayload, ingestItem, marker, sourcePath) {
|
||||
const references = Array.isArray(searchPayload?.references) ? searchPayload.references : [];
|
||||
return references.find((reference) => {
|
||||
const ids = referenceProviderIds(reference);
|
||||
const quote = `${reference.displayQuote || ""}\n${reference.rawQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& ids.knowledgeId === ingestItem.providerKnowledgeId
|
||||
&& (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function waitForSearchHit(context, marker, sourcePath, ingestItem) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
let lastError = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
try {
|
||||
lastPayload = await search(context, marker, sourcePath);
|
||||
const reference = findTargetReference(lastPayload, ingestItem, marker, sourcePath);
|
||||
if (reference) return { payload: lastPayload, reference };
|
||||
} catch (error) {
|
||||
lastError = {
|
||||
layer: error.layer || "search_poll",
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
};
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回目标 chunk 超时", {
|
||||
marker,
|
||||
sourcePath,
|
||||
providerKnowledgeId: ingestItem.providerKnowledgeId,
|
||||
lastError,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function rawWeKnoraChunkFromReference(reference) {
|
||||
const raw = reference?.reference;
|
||||
assert(raw && typeof raw === "object" && !Array.isArray(raw), `search reference 缺少原始 WeKnora chunk: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
return { provider: "weknora", ...raw };
|
||||
}
|
||||
|
||||
function assertMappedReference(reference, ingestItem, sourcePath, label) {
|
||||
assert.equal(reference?.provider, "weknora", `${label} provider 应为 weknora: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
const ids = referenceProviderIds(reference);
|
||||
assert(ids.knowledgeBaseId, `${label} 缺少 provider knowledgeBaseId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert(ids.knowledgeId, `${label} 缺少 provider knowledgeId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert(ids.chunkId, `${label} 缺少 provider chunkId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(ids.knowledgeId, ingestItem.providerKnowledgeId, `${label} provider knowledgeId 未保留 ingest 映射: ${JSON.stringify(ids)}`);
|
||||
assert.equal(reference.filePath ?? null, null, `${label} 不应把 WeKnora provider filename 伪造成 filePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.sourceRootRelativePath, sourcePath, `${label} 应通过 registry 映射回 sourceRootRelativePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(reference.citationDiagnostics?.providerFilenameIsNotLocalPath, true, `${label} 应声明 provider filename 不是本地路径真相: ${JSON.stringify(reference.citationDiagnostics, null, 2)}`);
|
||||
assert.equal(reference.openAction?.params?.path, sourcePath, `${label} openAction 应使用 registry path,不使用 provider filename: ${JSON.stringify(reference.openAction, null, 2)}`);
|
||||
}
|
||||
|
||||
async function openReference(context, rawChunk) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/open-reference`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
reference: rawChunk,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_open_reference_http", `knowledge-rag open-reference HTTP 失败: ${result.status}`, {
|
||||
response: result.payload || result.text,
|
||||
});
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.ok, true, `open-reference ok 应为 true: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
assert.equal(payload?.provider, "weknora", `open-reference provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expectedKbIds = configuredKnowledgeBaseIds();
|
||||
if (expectedKbIds.length === 0 || !hasWeKnoraApiKey()) {
|
||||
console.warn(JSON.stringify({
|
||||
warning: "local_weknora_env_not_visible",
|
||||
message: "当前 smoke shell 未看到 WeKnora env;继续以 MNote 3000 status 和真实 API 调用为准。",
|
||||
requiredAny: {
|
||||
knowledgeBaseId: ["MNOTE_WEKNORA_KNOWLEDGE_BASE_ID", "MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS"],
|
||||
apiKey: ["MNOTE_WEKNORA_API_KEY", "WEKNORA_API_KEY"],
|
||||
},
|
||||
hasKnowledgeBaseId: expectedKbIds.length > 0,
|
||||
hasApiKey: hasWeKnoraApiKey(),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const statusPayload = await status(context);
|
||||
try {
|
||||
assertWeKnoraStatus(statusPayload, expectedKbIds);
|
||||
} catch (error) {
|
||||
skip("mnote_weknora_provider_or_kb_not_configured", {
|
||||
assertion: error.message,
|
||||
providerConfig: statusPayload?.providerConfig || null,
|
||||
health: statusPayload?.health || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const marker = `task772 weknora e2e marker ${startedAt}`;
|
||||
const sourceName = `task772-weknora-e2e-${startedAt}.md`;
|
||||
const sourcePath = `${FIXTURE_DIR}/${sourceName}`;
|
||||
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
|
||||
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
absoluteSourcePath,
|
||||
[
|
||||
"# Task 772 WeKnora E2E",
|
||||
"",
|
||||
marker,
|
||||
"",
|
||||
"This markdown file is created by the MNote task772 smoke to verify real WeKnora ingest, search, and open-reference mapping.",
|
||||
"The provider filename must not become a forged local file path; MNote should map back through its source registry.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ingestResult = await ingest(context, sourcePath);
|
||||
const searchResult = await waitForSearchHit(context, marker, sourcePath, ingestResult.item);
|
||||
assert.equal(searchResult.payload?.provider, "weknora", `search provider 应为 weknora: ${JSON.stringify(searchResult.payload, null, 2).slice(0, 3000)}`);
|
||||
assertMappedReference(searchResult.reference, ingestResult.item, sourcePath, "search reference");
|
||||
|
||||
const rawChunk = rawWeKnoraChunkFromReference(searchResult.reference);
|
||||
const openPayload = await openReference(context, rawChunk);
|
||||
assertMappedReference(openPayload.reference, ingestResult.item, sourcePath, "open-reference");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
absoluteSourcePath,
|
||||
marker,
|
||||
provider: "weknora",
|
||||
providerKnowledgeBaseId: ingestResult.item.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: ingestResult.item.providerKnowledgeId,
|
||||
search: {
|
||||
resultCount: Array.isArray(searchResult.payload.results) ? searchResult.payload.results.length : 0,
|
||||
referenceCount: Array.isArray(searchResult.payload.references) ? searchResult.payload.references.length : 0,
|
||||
providerIds: referenceProviderIds(searchResult.reference),
|
||||
citationUrl: searchResult.reference.citationUrl || null,
|
||||
filePath: searchResult.reference.filePath ?? null,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath || null,
|
||||
},
|
||||
openReference: {
|
||||
providerIds: referenceProviderIds(openPayload.reference),
|
||||
citationUrl: openPayload.reference?.citationUrl || null,
|
||||
filePath: openPayload.reference?.filePath ?? null,
|
||||
sourceRootRelativePath: openPayload.reference?.sourceRootRelativePath || null,
|
||||
openAction: openPayload.reference?.openAction || null,
|
||||
},
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
ensureAuthenticated,
|
||||
getViewerIdentity,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task773-page-ai-openhub-browser-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser.png");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser-result.json");
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function visibleSelectorScript(selectors) {
|
||||
return selectors.some((selector) => {
|
||||
const nodes = Array.from(document.querySelectorAll(selector));
|
||||
return nodes.some((node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function assertServiceReachable(baseUrl) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl });
|
||||
}
|
||||
if (!response.ok && response.status !== 303) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl });
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithUiFirst(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (!page.url().includes("/auth")) {
|
||||
return getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first();
|
||||
const password = page.locator('input[name="password"], input[type="password"]').first();
|
||||
const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first();
|
||||
if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
||||
throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() });
|
||||
}
|
||||
await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||
await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
|
||||
await submit.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() });
|
||||
}
|
||||
|
||||
try {
|
||||
return await getViewerIdentity(requestContext);
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("auth_failed", `登录后 whoami 仍不可用:${error.message}`, { url: page.url() });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleAny(page, selectors, label) {
|
||||
try {
|
||||
await page.waitForFunction(visibleSelectorScript, selectors, { timeout: UI_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("selector_missing", `${label} 不可见。候选 selector: ${selectors.join(", ")}`, {
|
||||
selectors,
|
||||
cause: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openPageAiDrawer(page) {
|
||||
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth_failed", "打开主页后被重定向到 /auth,登录态未生效", { url: page.url() });
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.removeItem("mnote.page_ai.openhub_host");
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
} catch {}
|
||||
});
|
||||
await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口");
|
||||
|
||||
const drawerVisible = await page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
|
||||
if (!drawerVisible) {
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
|
||||
}
|
||||
|
||||
async function validateOpenHubQuickActions(page) {
|
||||
const frameHandle = await page.locator("iframe[data-page-ai-openhub-iframe]").elementHandle({ timeout: UI_TIMEOUT_MS });
|
||||
const frame = frameHandle ? await frameHandle.contentFrame() : null;
|
||||
if (!frame) {
|
||||
throw new SmokeFailure("quick_action_failed", "OpenHub iframe frame 不可用", { reason: "iframe_frame_missing" });
|
||||
}
|
||||
const tabButton = frame.locator("[data-mnote-openhub-current-tab-toggle]").first();
|
||||
const folderButton = frame.locator("[data-mnote-openhub-current-folder-toggle]").first();
|
||||
await tabButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await folderButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const fillVisibleInput = async (value) => frame.evaluate((nextValue) => {
|
||||
const selectors = [
|
||||
"textarea[placeholder*='输入']",
|
||||
"textarea",
|
||||
"[contenteditable='true']",
|
||||
"input[type='text'][placeholder*='输入']",
|
||||
"input[type='text']",
|
||||
".ant-input",
|
||||
];
|
||||
const visible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
for (const selector of selectors) {
|
||||
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
|
||||
if (!nodes.length) continue;
|
||||
const node = nodes[nodes.length - 1];
|
||||
node.focus();
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), "value");
|
||||
if (descriptor && typeof descriptor.set === "function") {
|
||||
descriptor.set.call(node, nextValue);
|
||||
} else {
|
||||
node.value = nextValue;
|
||||
}
|
||||
} else {
|
||||
node.textContent = nextValue;
|
||||
}
|
||||
node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" }));
|
||||
node.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, value);
|
||||
const readVisibleInput = async () => frame.evaluate(() => {
|
||||
const selectors = [
|
||||
"textarea[placeholder*='输入']",
|
||||
"textarea",
|
||||
"[contenteditable='true']",
|
||||
"input[type='text'][placeholder*='输入']",
|
||||
"input[type='text']",
|
||||
".ant-input",
|
||||
];
|
||||
const visible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
for (const selector of selectors) {
|
||||
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
|
||||
if (!nodes.length) continue;
|
||||
const node = nodes[nodes.length - 1];
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) return node.value || "";
|
||||
return node.textContent || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const hasInput = await fillVisibleInput("");
|
||||
if (!hasInput) {
|
||||
throw new SmokeFailure("quick_action_failed", "OpenHub iframe 内未找到可见聊天输入框", {});
|
||||
}
|
||||
const prompt = `MNOTE_NATIVE_CONTEXT_SMOKE_${Date.now()}`;
|
||||
let capturedBody = null;
|
||||
await page.route("**/page-ai/openhub/ai/api/query/stream**", async (route) => {
|
||||
capturedBody = JSON.parse(route.request().postData() || "{}");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body: [
|
||||
`data: ${JSON.stringify({ type: "session", conversation_id: "task773-native-context", done: false })}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({ type: "message_complete", done: true })}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
await fillVisibleInput(prompt);
|
||||
await tabButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const inputTextAfterToggle = (await readVisibleInput()).trim();
|
||||
const tabSelected = await tabButton.evaluate((node) => node.classList.contains("ant-btn-primary") || node.getAttribute("type") === "button" && node.matches(".ant-btn-primary"));
|
||||
const folderSelectedAfterTab = await folderButton.evaluate((node) => node.classList.contains("ant-btn-primary"));
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction(() => window.__mnoteOpenHubTask773RequestCaptured === true, undefined, { timeout: 100 }).catch(() => undefined);
|
||||
const started = Date.now();
|
||||
while (!capturedBody && Date.now() - started < UI_TIMEOUT_MS) {
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const activeEditor = await page.evaluate(() => window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === "function"
|
||||
? window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()?.activeEditor || null
|
||||
: null);
|
||||
const result = {
|
||||
ok: Boolean(capturedBody && capturedBody.mnote_context && capturedBody.mnote_context.value),
|
||||
tabButtonVisible: await tabButton.isVisible().catch(() => false),
|
||||
folderButtonVisible: await folderButton.isVisible().catch(() => false),
|
||||
tabSelected,
|
||||
folderSelectedAfterTab,
|
||||
inputTextAfterToggle,
|
||||
requestQuestion: capturedBody && capturedBody.question,
|
||||
mnoteContext: capturedBody && capturedBody.mnote_context,
|
||||
activeEditor,
|
||||
};
|
||||
if (!result.ok) {
|
||||
throw new SmokeFailure("quick_action_failed", "OpenHub 当前 Tab/文件夹上下文未随发送请求进入后台", result);
|
||||
}
|
||||
if (!result.tabSelected || result.folderSelectedAfterTab) {
|
||||
throw new SmokeFailure("quick_action_selection_invalid", "当前 Tab 按钮没有呈现单选选中态", result);
|
||||
}
|
||||
if (result.inputTextAfterToggle !== prompt) {
|
||||
throw new SmokeFailure("quick_action_leaked_to_input", "当前 Tab/文件夹地址不应直接写入用户可见输入框", result);
|
||||
}
|
||||
if (result.mnoteContext.kind !== "tab" || !/^https?:\/\/.+\/documents\//.test(result.mnoteContext.value)) {
|
||||
throw new SmokeFailure("quick_action_tab_context_invalid", "当前 Tab 发送上下文不是 MNote 文档地址", result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectState(page) {
|
||||
return page.evaluate(() => {
|
||||
const visible = (node) => {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
const ownerWindow = node.ownerDocument && node.ownerDocument.defaultView ? node.ownerDocument.defaultView : window;
|
||||
const style = ownerWindow.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const rectOf = (node) => {
|
||||
if (!node || node.nodeType !== 1 || typeof node.getBoundingClientRect !== "function") return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
top: Math.round(rect.top),
|
||||
bottom: Math.round(rect.bottom),
|
||||
};
|
||||
};
|
||||
const visibleElements = (selector) => Array.from(document.querySelectorAll(selector)).filter(visible);
|
||||
const visibleAny = (selector) => visibleElements(selector).length > 0;
|
||||
const text = (selector) => (document.querySelector(selector)?.textContent || "").trim();
|
||||
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
const iframeDoc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
|
||||
const iframeBodyText = (iframeDoc && iframeDoc.body ? iframeDoc.body.textContent || "" : "").trim();
|
||||
const iframeShellKind = iframeDoc && iframeDoc.body ? iframeDoc.body.getAttribute("data-mnote-openhub-ai-shell") || "" : "";
|
||||
const drawer = Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible) || null;
|
||||
const openhubHost = document.querySelector("[data-page-ai-openhub-host='true']");
|
||||
const outerOpenHubHeaderVisible = visibleAny(".wolai-page-ai-opencode-header")
|
||||
|| visibleAny(".wolai-page-ai-header-copy")
|
||||
|| visibleAny("[data-page-ai-openhub-runtime-status]");
|
||||
const diagnostics = document.querySelector("[data-page-ai-openhub-bootstrap-copy]");
|
||||
const diagnosticsVisible = Boolean(diagnostics && visible(diagnostics));
|
||||
const diagnosticsRect = diagnosticsVisible ? rectOf(diagnostics) : null;
|
||||
const diagnosticsOpen = diagnostics instanceof HTMLDetailsElement ? diagnostics.open : false;
|
||||
const debugChromeRects = diagnosticsVisible ? [{
|
||||
selector: "[data-page-ai-openhub-bootstrap-copy]",
|
||||
text: (diagnostics.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160),
|
||||
rect: diagnosticsRect,
|
||||
open: diagnosticsOpen,
|
||||
}] : [];
|
||||
const debugChromeTotalHeight = diagnosticsRect?.height || 0;
|
||||
const fallbackActionSelector = "[data-page-ai-action='openhub-use-opencode-fallback']";
|
||||
const fallbackActionVisibleInIframe = iframeDoc
|
||||
? Array.from(iframeDoc.querySelectorAll(fallbackActionSelector)).some(visible)
|
||||
: false;
|
||||
const pageText = (document.body.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const loginTextPattern = /(登录\s*OpenHub|OpenHub\s*Login|WeKnora\s*登录|登录\s*WeKnora|Sign in to OpenHub|OpenHub account|WeKnora account)/i;
|
||||
const reactSelectorMarkers = [
|
||||
"[data-openhub-ai-panel]",
|
||||
"[data-testid='openhub-ai-panel']",
|
||||
"[data-testid='openhub-chat']",
|
||||
"[data-openhub-conversation]",
|
||||
"[data-openhub-session-history]",
|
||||
".openhub-ai-panel",
|
||||
".chat-message-list",
|
||||
".ant-layout",
|
||||
".ant-menu",
|
||||
".ant-input",
|
||||
].filter((selector) => Array.from(document.querySelectorAll(selector)).some(visible)
|
||||
|| (iframeDoc && Array.from(iframeDoc.querySelectorAll(selector)).some(visible)));
|
||||
const reactTextMarkers = [
|
||||
"OpenHub 平台",
|
||||
"开始对话",
|
||||
"历史记录",
|
||||
"技能管理",
|
||||
"选择模型",
|
||||
].filter((marker) => iframeBodyText.includes(marker));
|
||||
const conflictEntryPattern = /(文件管理|知识库|时光机|智能体|协作任务|团队状态)/;
|
||||
const quickActionTab = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-tab-toggle]") : null;
|
||||
const quickActionFolder = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-folder-toggle]") : null;
|
||||
const smokeInput = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-smoke-input]") : null;
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
bodySnippet: pageText.slice(0, 800),
|
||||
drawerVisible: Boolean(drawer),
|
||||
drawerRect: rectOf(drawer),
|
||||
openhubHost: Boolean(openhubHost),
|
||||
openhubHostRect: rectOf(openhubHost),
|
||||
outerOpenHubHeaderVisible,
|
||||
debugChromeVisible: debugChromeRects.length > 0,
|
||||
debugChromeRects,
|
||||
debugChromeTotalHeight,
|
||||
debugChromeSqueezesContent: diagnosticsOpen || debugChromeTotalHeight > 80,
|
||||
hostChromeVisible: Boolean(Array.from(document.querySelectorAll("[data-page-ai-openhub-bootstrap-copy]")).find(visible)),
|
||||
iframeVisible: iframe instanceof HTMLIFrameElement && visible(iframe),
|
||||
iframeRect: rectOf(iframe),
|
||||
iframeSrc: iframe instanceof HTMLIFrameElement ? iframe.getAttribute("src") || "" : "",
|
||||
iframeBodySnippet: iframeBodyText.slice(0, 800),
|
||||
iframeShellKind,
|
||||
runtimeStatus: text("[data-page-ai-openhub-runtime-status]"),
|
||||
authTruth: text("[data-page-ai-openhub-auth-truth]"),
|
||||
workspaceScope: text("[data-page-ai-openhub-workspace-scope]"),
|
||||
routeGuard: text("[data-page-ai-openhub-route-guard]"),
|
||||
fallback: text("[data-page-ai-openhub-fallback]"),
|
||||
loginPageVisible: loginTextPattern.test(pageText) || loginTextPattern.test(iframeBodyText),
|
||||
staticBoundaryVisible: iframeShellKind === "static-boundary" || /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(iframeBodyText),
|
||||
fallbackActionVisible: visibleAny(fallbackActionSelector) || fallbackActionVisibleInIframe,
|
||||
fallbackActionCount: document.querySelectorAll(fallbackActionSelector).length
|
||||
+ (iframeDoc ? iframeDoc.querySelectorAll(fallbackActionSelector).length : 0),
|
||||
reactAiMarkers: [...reactSelectorMarkers, ...reactTextMarkers.map((marker) => `text:${marker}`)],
|
||||
reactTextMarkers,
|
||||
conflictEntryVisible: conflictEntryPattern.test(iframeBodyText),
|
||||
quickActionTabVisible: Boolean(quickActionTab && visible(quickActionTab)),
|
||||
quickActionFolderVisible: Boolean(quickActionFolder && visible(quickActionFolder)),
|
||||
quickActionTabText: quickActionTab ? (quickActionTab.textContent || "").trim() : "",
|
||||
quickActionFolderText: quickActionFolder ? (quickActionFolder.textContent || "").trim() : "",
|
||||
quickActionTabLabel: quickActionTab ? quickActionTab.getAttribute("aria-label") || "" : "",
|
||||
quickActionFolderLabel: quickActionFolder ? quickActionFolder.getAttribute("aria-label") || "" : "",
|
||||
quickActionTabSelected: quickActionTab ? quickActionTab.classList.contains("ant-btn-primary") : false,
|
||||
quickActionFolderSelected: quickActionFolder ? quickActionFolder.classList.contains("ant-btn-primary") : false,
|
||||
quickActionSmokeInputValue: smokeInput instanceof HTMLTextAreaElement ? smokeInput.value : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function assertOpenHubState(state) {
|
||||
if (!state.drawerVisible) {
|
||||
throw new SmokeFailure("selector_missing", "Page AI drawer 未保持可见", state);
|
||||
}
|
||||
if (!state.openhubHost) {
|
||||
throw new SmokeFailure("selector_missing", "Page AI drawer 未切到 OpenHub host", state);
|
||||
}
|
||||
if (!state.iframeVisible || !state.iframeSrc.includes("/page-ai/openhub/ai")) {
|
||||
throw new SmokeFailure("selector_missing", "OpenHub iframe 不可见或 src 未指向 /page-ai/openhub/ai", state);
|
||||
}
|
||||
if (state.outerOpenHubHeaderVisible) {
|
||||
throw new SmokeFailure("outer_header_visible", "OpenHub drawer 仍显示 MNote 外层 OpenHub AI 标题栏", state);
|
||||
}
|
||||
if (!state.iframeRect || state.iframeRect.height < 420) {
|
||||
throw new SmokeFailure("iframe_too_short", "OpenHub iframe 高度不足,可能被顶部 debug/status 区挤压", state);
|
||||
}
|
||||
if (state.debugChromeSqueezesContent) {
|
||||
throw new SmokeFailure("debug_chrome_visible", "OpenHub 顶部 debug/status chrome 仍可见且挤占空间", state);
|
||||
}
|
||||
if (state.fallbackActionVisible || state.fallbackActionCount > 0) {
|
||||
throw new SmokeFailure("legacy_fallback_visible", "OpenHub drawer 仍存在用户可见 opencode fallback 入口", state);
|
||||
}
|
||||
if (state.loginPageVisible) {
|
||||
throw new SmokeFailure("unexpected_upstream_login", "页面出现 OpenHub/WeKnora 登录入口", state);
|
||||
}
|
||||
if (!state.iframeBodySnippet && !state.reactAiMarkers.length) {
|
||||
throw new SmokeFailure("selector_missing", "OpenHub iframe 已出现,但 iframe 内 shell 内容不可见", state);
|
||||
}
|
||||
if (state.conflictEntryVisible) {
|
||||
throw new SmokeFailure("unexpected_conflict_entry", "OpenHub iframe 嵌入态仍显示 MNote 真相冲突入口", state);
|
||||
}
|
||||
if (!state.quickActionTabVisible || !state.quickActionFolderVisible) {
|
||||
throw new SmokeFailure("quick_action_missing", "OpenHub iframe 内缺少当前 Tab/文件夹快捷按钮", state);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const consoleMessages = [];
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
let result;
|
||||
let directHostRoute = null;
|
||||
let legacyOpencodeRoute = null;
|
||||
const networkEvents = [];
|
||||
|
||||
try {
|
||||
await assertServiceReachable(BASE_URL);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
page = await context.newPage();
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes("/page-ai/openhub")) {
|
||||
networkEvents.push({ url, status: response.status(), contentType: response.headers()["content-type"] || "" });
|
||||
}
|
||||
});
|
||||
|
||||
const viewer = await loginWithUiFirst(page, context.request).catch(async (error) => {
|
||||
if (error instanceof SmokeFailure) throw error;
|
||||
await ensureAuthenticated(page, context.request);
|
||||
return getViewerIdentity(context.request);
|
||||
});
|
||||
legacyOpencodeRoute = await context.request.get(`${BASE_URL}/page-ai/opencode`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
|
||||
url: `${BASE_URL}/page-ai/opencode`,
|
||||
status: response.status(),
|
||||
ok: response.ok(),
|
||||
contentType: response.headers()["content-type"] || "",
|
||||
bodySnippet: (await response.text()).slice(0, 240),
|
||||
})).catch((error) => ({
|
||||
url: `${BASE_URL}/page-ai/opencode`,
|
||||
status: 0,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
}));
|
||||
if (legacyOpencodeRoute.status !== 410) {
|
||||
throw new SmokeFailure("legacy_fallback_route_enabled", "登录后 /page-ai/opencode legacy fallback 页面仍可访问", legacyOpencodeRoute);
|
||||
}
|
||||
directHostRoute = await context.request.get(`${BASE_URL}/page-ai/openhub/ai`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
|
||||
url: `${BASE_URL}/page-ai/openhub/ai`,
|
||||
status: response.status(),
|
||||
ok: response.ok(),
|
||||
contentType: response.headers()["content-type"] || "",
|
||||
bodySnippet: (await response.text()).slice(0, 240),
|
||||
})).catch((error) => ({
|
||||
url: `${BASE_URL}/page-ai/openhub/ai`,
|
||||
status: 0,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
}));
|
||||
await openPageAiDrawer(page);
|
||||
await waitForVisibleAny(
|
||||
page,
|
||||
[
|
||||
"[data-page-ai-openhub-host='true']",
|
||||
"[data-page-ai-openhub-bootstrap-copy]",
|
||||
"iframe[data-page-ai-openhub-iframe]",
|
||||
],
|
||||
"OpenHub host",
|
||||
);
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("/page-ai/openhub/ai");
|
||||
}, undefined, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("mnoteScope=");
|
||||
}, undefined, { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.waitForTimeout(800);
|
||||
await page.waitForFunction(() => {
|
||||
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
const doc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
|
||||
return Boolean(doc && doc.querySelector("[data-mnote-openhub-current-tab-toggle]") && doc.querySelector("[data-mnote-openhub-current-folder-toggle]"));
|
||||
}, undefined, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const quickActions = await validateOpenHubQuickActions(page);
|
||||
const state = await collectState(page);
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
assertOpenHubState(state);
|
||||
const openhubReactConnected = state.reactAiMarkers.length > 0;
|
||||
|
||||
result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
viewer,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
directHostRoute,
|
||||
legacyOpencodeRoute,
|
||||
quickActions,
|
||||
state,
|
||||
openhubReactConnected,
|
||||
staticBoundaryOnly: state.staticBoundaryVisible && !openhubReactConnected,
|
||||
reactAiNote: openhubReactConnected
|
||||
? "OpenHub React UI marker 已出现"
|
||||
: "未发现 OpenHub React AI marker;本次只验证 MNote OpenHub host drawer/iframe/shell 边界,不把静态 shell 记为 React AI 已接入",
|
||||
networkEvents,
|
||||
consoleMessages,
|
||||
};
|
||||
} catch (error) {
|
||||
const state = page ? await collectState(page).catch(() => null) : null;
|
||||
if (page) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
}
|
||||
result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
screenshot: fs.existsSync(SCREENSHOT_PATH) ? SCREENSHOT_PATH : null,
|
||||
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
errorDetails: error instanceof SmokeFailure ? error.details : null,
|
||||
directHostRoute,
|
||||
legacyOpencodeRoute,
|
||||
state,
|
||||
networkEvents,
|
||||
consoleMessages,
|
||||
};
|
||||
} finally {
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (context) await context.close().catch(() => undefined);
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
getViewerIdentity,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task774-openhub-mnote-send-and-file-edit-e2e";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "openhub-send-smoke-result.json");
|
||||
const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|
||||
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = `file://${WORKSPACE_ROOT}`;
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const ENABLE_FILE_EDIT = process.env.MNOTE_OPENHUB_FILE_EDIT === "1";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertServiceReachable(baseUrl) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl });
|
||||
}
|
||||
if (!response.ok && response.status !== 303) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl });
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithUiFirst(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (!page.url().includes("/auth")) {
|
||||
return getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first();
|
||||
const password = page.locator('input[name="password"], input[type="password"]').first();
|
||||
const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first();
|
||||
if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
||||
throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() });
|
||||
}
|
||||
await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||
await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
|
||||
await submit.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() });
|
||||
}
|
||||
|
||||
return getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
function mnoteScopeQuery(openhubIframeUrl) {
|
||||
const queryStart = openhubIframeUrl.indexOf("?");
|
||||
if (queryStart < 0) return "";
|
||||
return openhubIframeUrl.slice(queryStart);
|
||||
}
|
||||
|
||||
function parseStream(streamText) {
|
||||
let sessionId = "";
|
||||
let assistantText = "";
|
||||
const eventTypes = [];
|
||||
for (const line of streamText.split(/\r?\n/)) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const payload = data.payload && data.payload.type
|
||||
? { type: data.payload.type, ...data.payload.properties }
|
||||
: data;
|
||||
if (payload.type) eventTypes.push(payload.type);
|
||||
if (payload.conversation_id) sessionId = payload.conversation_id;
|
||||
if (["text", "content", "assistant_message", "message"].includes(payload.type) && payload.content) {
|
||||
assistantText += payload.content;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
eventTypes: [...new Set(eventTypes)],
|
||||
assistantTextSnippet: assistantText.slice(0, 700),
|
||||
};
|
||||
}
|
||||
|
||||
async function bootstrapOpenHub(requestContext) {
|
||||
const response = await requestContext.post(`${BASE_URL}/api/page-ai/openhub/bootstrap`, {
|
||||
data: {
|
||||
pageId: "task774-openhub-send-smoke",
|
||||
workspaceId: "local-ws:mnote-e2e:my-space",
|
||||
pageTitle: "OpenHub send smoke",
|
||||
rootUri: ROOT_URI,
|
||||
allowedRoots: [],
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok() || !payload.openhubIframeUrl) {
|
||||
throw new SmokeFailure("bootstrap_failed", `OpenHub bootstrap 失败:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchOpenHubModels(requestContext, scopeQuery) {
|
||||
const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/models${scopeQuery}`, { timeout: UI_TIMEOUT_MS });
|
||||
const payload = await response.json();
|
||||
const models = payload?.data?.models || [];
|
||||
const selected = models.find((model) => model.providerID === "opencodego" && model.modelID === "deepseek-v4-flash")
|
||||
|| models.find((model) => model.providerID === "opencode" && model.modelID === "deepseek-v4-flash-free")
|
||||
|| models.find((model) => model.providerID === "opencodego")
|
||||
|| models.find((model) => model.providerID === "opencode")
|
||||
|| payload?.data?.default
|
||||
|| models[0];
|
||||
if (!response.ok() || !models.length || !selected) {
|
||||
throw new SmokeFailure("models_empty", `OpenHub /api/models 未返回可用真实模型:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return {
|
||||
modelCount: models.length,
|
||||
default: payload.data.default,
|
||||
source: payload.data.source,
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendPrompt(requestContext, scopeQuery, model, prompt) {
|
||||
const response = await requestContext.post(`${BASE_URL}/page-ai/openhub/ai/api/query/stream${scopeQuery}`, {
|
||||
data: {
|
||||
question: prompt,
|
||||
conversation_id: "",
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
currentUsage: model.currentUsage || 0,
|
||||
monthlyLimit: model.monthlyLimit || 0,
|
||||
},
|
||||
},
|
||||
headers: { "content-type": "application/json" },
|
||||
timeout: 180_000,
|
||||
});
|
||||
const streamText = await response.text();
|
||||
if (!response.ok()) {
|
||||
throw new SmokeFailure("query_stream_failed", `OpenHub query stream 失败:HTTP ${response.status()}`, {
|
||||
snippet: streamText.slice(0, 800),
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: response.status(),
|
||||
length: streamText.length,
|
||||
snippet: streamText.slice(0, 1_200),
|
||||
...parseStream(streamText),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMessages(requestContext, scopeQuery, sessionId) {
|
||||
const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(sessionId)}/messages${scopeQuery}`, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json();
|
||||
const messages = payload?.data || [];
|
||||
if (!response.ok() || !messages.some((message) => message.role === "user")) {
|
||||
throw new SmokeFailure("messages_not_persisted", `OpenHub SQLite messages 未持久化或不可读:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return {
|
||||
status: response.status(),
|
||||
count: messages.length,
|
||||
roles: messages.map((message) => message.role),
|
||||
last: messages.slice(-2).map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").slice(0, 300),
|
||||
model: message.model,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function runFileEditProbe(requestContext, scopeQuery, model) {
|
||||
const fixtureDir = path.join(WORKSPACE_ROOT, "knowledge-rag-fixtures-7-68");
|
||||
fs.mkdirSync(fixtureDir, { recursive: true });
|
||||
const fixturePath = path.join(fixtureDir, `task774-openhub-file-edit-${Date.now()}.md`);
|
||||
fs.writeFileSync(fixturePath, "# OpenHub File Edit Smoke\n\nstatus: pending\n", "utf8");
|
||||
const gitDir = path.join(WORKSPACE_ROOT, ".git");
|
||||
const gitExistedBefore = fs.existsSync(gitDir);
|
||||
const prompt = [
|
||||
`请直接修改这个文件:${fixturePath}`,
|
||||
"只把 `status: pending` 改成 `status: MNOTE_OPENHUB_FILE_EDIT_OK`。",
|
||||
"不要改其它文件。完成后只简短说明已修改。",
|
||||
].join("\n");
|
||||
const stream = await sendPrompt(requestContext, scopeQuery, model, prompt);
|
||||
const finalContent = fs.readFileSync(fixturePath, "utf8");
|
||||
const ok = finalContent.includes("status: MNOTE_OPENHUB_FILE_EDIT_OK");
|
||||
if (!ok) {
|
||||
throw new SmokeFailure("file_edit_not_applied", "OpenHub/opencode 未把 fixture 文件改到期望内容", {
|
||||
fixturePath,
|
||||
finalContent,
|
||||
stream,
|
||||
});
|
||||
}
|
||||
return {
|
||||
fixturePath,
|
||||
ok,
|
||||
finalContent,
|
||||
gitExistedBefore,
|
||||
gitExistsAfter: fs.existsSync(gitDir),
|
||||
stream,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
let browser;
|
||||
let context;
|
||||
let result = { ok: false, task: TASK, baseUrl: BASE_URL };
|
||||
try {
|
||||
await assertServiceReachable(BASE_URL);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
const viewer = await loginWithUiFirst(page, context.request);
|
||||
const bootstrap = await bootstrapOpenHub(context.request);
|
||||
const scopeQuery = mnoteScopeQuery(bootstrap.openhubIframeUrl);
|
||||
const models = await fetchOpenHubModels(context.request, scopeQuery);
|
||||
const smokePrompt = `请只回复 MNOTE_OPENHUB_SMOKE_OK,不要解释。时间戳 ${Date.now()}`;
|
||||
const stream = await sendPrompt(context.request, scopeQuery, models.selected, smokePrompt);
|
||||
if (!stream.sessionId) {
|
||||
throw new SmokeFailure("query_stream_missing_session", "OpenHub query stream 未返回 conversation_id", stream);
|
||||
}
|
||||
const messages = await fetchMessages(context.request, scopeQuery, stream.sessionId);
|
||||
const fileEdit = ENABLE_FILE_EDIT
|
||||
? await runFileEditProbe(context.request, scopeQuery, models.selected)
|
||||
: { skipped: true, reason: "设置 MNOTE_OPENHUB_FILE_EDIT=1 后执行真实文件编辑验收" };
|
||||
result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
viewer,
|
||||
bootstrap: {
|
||||
ok: bootstrap.ok,
|
||||
authTruth: bootstrap.authTruth,
|
||||
iframeUrlPrefix: bootstrap.openhubIframeUrl.slice(0, 160),
|
||||
rootUri: bootstrap.scope?.workspaceScope?.rootUri,
|
||||
},
|
||||
models,
|
||||
stream,
|
||||
messages,
|
||||
fileEdit,
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
details: error instanceof SmokeFailure ? error.details : undefined,
|
||||
};
|
||||
} finally {
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (context) await context.close().catch(() => undefined);
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const TASK = "task775-openhub-mnote-scope-isolation-smoke";
|
||||
const OPENHUB_BASE_URL = (process.env.MNOTE_OPENHUB_BASE_URL || "http://127.0.0.1:18080").replace(/\/+$/, "");
|
||||
const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|
||||
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_OPENHUB_SCOPE_ROOT_URI || `file://${WORKSPACE_ROOT}`;
|
||||
const WORKSPACE_KEY = process.env.MNOTE_OPENHUB_SCOPE_WORKSPACE_KEY || "local-ws:mnote-e2e:my-space";
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_TIMEOUT_MS || 15_000);
|
||||
const STREAM_PRIME_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_STREAM_PRIME_MS || 3_000);
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildScopeHeaders(ownerLabel, sessionScope) {
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
"X-MNote-User-Key": `task775:${ownerLabel}`,
|
||||
"X-MNote-Workspace-Key": WORKSPACE_KEY,
|
||||
"X-MNote-Session-Scope": sessionScope,
|
||||
"X-MNote-Root-Uri": ROOT_URI,
|
||||
"X-MNote-Page-Resource-Id": "task775-openhub-scope-isolation",
|
||||
"X-MNote-Tool-Permission-Scope": JSON.stringify({
|
||||
source: TASK,
|
||||
allowedRoots: [ROOT_URI],
|
||||
}),
|
||||
"X-MNote-WeKnora-Tool-Scope": JSON.stringify({
|
||||
source: TASK,
|
||||
enabled: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
const text = await response.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOpenHubReachable(headers) {
|
||||
const response = await fetchWithTimeout(`${OPENHUB_BASE_URL}/api/sessions?page=1&page_size=1`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
const payload = await readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new SmokeFailure("openhub_unreachable", `OpenHub MNote scope API 不可用:HTTP ${response.status}`, {
|
||||
baseUrl: OPENHUB_BASE_URL,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
success: payload?.success === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function primeSessionWithOwnerA(headers, sessionId, marker) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), STREAM_PRIME_MS);
|
||||
let response;
|
||||
let streamSnippet = "";
|
||||
try {
|
||||
response = await fetch(`${OPENHUB_BASE_URL}/api/query/stream`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
question: `请只回复 ${marker},不要解释。`,
|
||||
conversation_id: sessionId,
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_PROVIDER || "opencodego",
|
||||
modelID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_ID || "deepseek-v4-flash",
|
||||
currentUsage: 0,
|
||||
monthlyLimit: 0,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await readJsonResponse(response);
|
||||
throw new SmokeFailure("query_stream_failed", `Owner A 创建 session/message 失败:HTTP ${response.status}`, {
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (streamSnippet.length < 2_000) {
|
||||
const readResult = await Promise.race([
|
||||
reader.read(),
|
||||
delay(250).then(() => ({ timedOut: true })),
|
||||
]);
|
||||
if (readResult.timedOut) break;
|
||||
if (readResult.done) break;
|
||||
streamSnippet += decoder.decode(readResult.value, { stream: true });
|
||||
if (streamSnippet.includes(marker) || streamSnippet.includes("\"type\"")) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.name !== "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
return {
|
||||
status: response?.status || 0,
|
||||
streamSnippet: streamSnippet.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMessages(headers, sessionId) {
|
||||
const response = await fetchWithTimeout(
|
||||
`${OPENHUB_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
{
|
||||
method: "GET",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const payload = await readJsonResponse(response);
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForOwnerAMessages(headers, sessionId, marker) {
|
||||
let last = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
last = await fetchMessages(headers, sessionId);
|
||||
const messages = Array.isArray(last.payload?.data) ? last.payload.data : [];
|
||||
const hasMarker = messages.some((message) => String(message.content || "").includes(marker));
|
||||
if (last.ok && hasMarker) {
|
||||
return {
|
||||
status: last.status,
|
||||
readable: true,
|
||||
count: messages.length,
|
||||
roles: messages.map((message) => message.role),
|
||||
markerFound: true,
|
||||
sample: messages.slice(-3).map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").slice(0, 240),
|
||||
})),
|
||||
};
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
throw new SmokeFailure("owner_a_messages_not_readable", "Owner A 未能读取到自己创建的 session/messages", {
|
||||
last,
|
||||
});
|
||||
}
|
||||
|
||||
function assertOwnerBBlocked(ownerBResult) {
|
||||
if (ownerBResult.status === 403 || ownerBResult.status === 404) {
|
||||
return {
|
||||
blocked: true,
|
||||
status: ownerBResult.status,
|
||||
detail: ownerBResult.payload?.detail || ownerBResult.payload,
|
||||
};
|
||||
}
|
||||
|
||||
throw new SmokeFailure("owner_b_not_blocked", "Owner B 读取到了或可访问 Owner A 的 session/messages", {
|
||||
status: ownerBResult.status,
|
||||
payload: ownerBResult.payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const sessionId = `task775-openhub-scope-${runId}`;
|
||||
const sessionScope = `task775-openhub-scope-isolation:${runId}`;
|
||||
const marker = `MNOTE_OPENHUB_SCOPE_ISOLATION_${runId}`;
|
||||
const headersA = buildScopeHeaders("owner-a", sessionScope);
|
||||
const headersB = buildScopeHeaders("owner-b", sessionScope);
|
||||
let result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
mode: "direct-openhub-mnote-headers-backend-scope-isolation",
|
||||
openhubBaseUrl: OPENHUB_BASE_URL,
|
||||
sessionId,
|
||||
};
|
||||
|
||||
try {
|
||||
const health = await assertOpenHubReachable(headersA);
|
||||
const stream = await primeSessionWithOwnerA(headersA, sessionId, marker);
|
||||
const ownerA = await waitForOwnerAMessages(headersA, sessionId, marker);
|
||||
const ownerB = assertOwnerBBlocked(await fetchMessages(headersB, sessionId));
|
||||
result = {
|
||||
...result,
|
||||
ok: true,
|
||||
health,
|
||||
stream,
|
||||
ownerA,
|
||||
ownerB,
|
||||
summary: {
|
||||
sessionId,
|
||||
ownerAReadable: ownerA.readable,
|
||||
ownerBBlocked: ownerB.blocked,
|
||||
ownerBStatus: ownerB.status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
details: error instanceof SmokeFailure ? error.details : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
|
||||
const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py");
|
||||
const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js");
|
||||
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
|
||||
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
|
||||
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
|
||||
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
|
||||
const task774ResultPath = path.join(repoRoot, "tmp/7-68-runtime/openhub-send-smoke-result.json");
|
||||
const outputDir = path.join(repoRoot, "tmp/7-68-runtime");
|
||||
const resultPath = path.join(outputDir, "openhub-changed-files-bridge-smoke-result.json");
|
||||
const baseUrl = process.env.MNOTE_BASE_URL || "http://127.0.0.1:3000";
|
||||
const testAccount = process.env.MNOTE_E2E_ACCOUNT || "mnote-e2e";
|
||||
const testPassword = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
const workspaceRoot = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|
||||
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
function loadTask774Probe() {
|
||||
if (!fs.existsSync(task774ResultPath)) return null;
|
||||
const payload = JSON.parse(fs.readFileSync(task774ResultPath, "utf8"));
|
||||
const sessionId = payload?.fileEdit?.stream?.sessionId;
|
||||
const fixturePath = payload?.fileEdit?.fixturePath;
|
||||
if (!payload?.ok || !sessionId || !fixturePath) return null;
|
||||
return { sessionId, fixturePath };
|
||||
}
|
||||
|
||||
async function signInCookie() {
|
||||
const response = await fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: testAccount,
|
||||
password: testPassword,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`/api/auth 登录失败: HTTP ${response.status} ${text.slice(0, 400)}`);
|
||||
}
|
||||
const setCookie = response.headers.get("set-cookie") || "";
|
||||
const cookies = setCookie
|
||||
.split(/,(?=\s*[^;,\s]+=)/)
|
||||
.map((part) => part.split(";")[0].trim())
|
||||
.filter(Boolean);
|
||||
if (!cookies.some((cookie) => cookie.startsWith("mnote_session="))) {
|
||||
throw new Error(`/api/auth 未返回 mnote_session cookie: ${setCookie.slice(0, 400)}`);
|
||||
}
|
||||
return cookies.join("; ");
|
||||
}
|
||||
|
||||
async function bootstrapScopeQuery(cookieHeader) {
|
||||
const response = await fetch(`${baseUrl}/api/page-ai/openhub/bootstrap`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: "local-ws:mnote-e2e:my-space",
|
||||
rootUri: `file://${workspaceRoot}`,
|
||||
pageResourceId: "task776-openhub-changed-files-bridge",
|
||||
pageTitle: "OpenHub changed files bridge smoke",
|
||||
allowedRoots: [`file://${workspaceRoot}`],
|
||||
}),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenHub bootstrap 失败: HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`);
|
||||
}
|
||||
const iframeUrl = String(payload?.openhubIframeUrl || "");
|
||||
const query = iframeUrl.includes("?") ? iframeUrl.slice(iframeUrl.indexOf("?")) : "";
|
||||
if (!query.includes("mnoteScope=")) {
|
||||
throw new Error(`OpenHub bootstrap 未返回完整 mnoteScope query: ${iframeUrl.slice(0, 200)}`);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
async function probeLiveEndpoint(probe) {
|
||||
if (!probe) {
|
||||
return { skipped: true, reason: "缺少 task774 真实文件编辑结果,先运行 MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js" };
|
||||
}
|
||||
const cookieHeader = await signInCookie();
|
||||
const query = await bootstrapScopeQuery(cookieHeader);
|
||||
const url = `${baseUrl}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(probe.sessionId)}/diff${query}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json", cookie: cookieHeader },
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {}
|
||||
const changed = Array.isArray(payload?.diffs) ? payload.diffs : [];
|
||||
const matched = changed.some((item) => item.path === probe.fixturePath);
|
||||
return {
|
||||
skipped: false,
|
||||
ok: response.ok && matched,
|
||||
status: response.status,
|
||||
matched,
|
||||
expectedPath: probe.fixturePath,
|
||||
queryFromFreshBootstrap: true,
|
||||
changedPaths: changed.map((item) => item.path),
|
||||
diffAvailable: payload?.diffAvailable,
|
||||
source: payload?.source,
|
||||
limitation: payload?.limitation,
|
||||
snippet: text.slice(0, 800),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const failures = [];
|
||||
const backendSession = read(backendSessionPath);
|
||||
const frontendApi = read(frontendApiPath);
|
||||
const diffViewer = read(diffViewerPath);
|
||||
const embed = read(embedPath);
|
||||
const smartQuery = read(smartQueryPath);
|
||||
const mnoteRuntime = read(mnoteRuntimePath);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub backend exposes session diff endpoint",
|
||||
backendSession.includes('@router.get("/api/sessions/{session_id}/diff")') &&
|
||||
backendSession.includes("_changed_files_from_messages") &&
|
||||
backendSession.includes("opencode_tool_events")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"backend extracts path only from write-like opencode tool metadata",
|
||||
backendSession.includes("_is_write_tool") &&
|
||||
backendSession.includes("_iter_tool_path_values") &&
|
||||
backendSession.includes('"filePath"') &&
|
||||
backendSession.includes('"source": "opencode_tool_event"')
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"backend guards workspace path and does not synthesize diff content",
|
||||
backendSession.includes("os.path.commonpath") &&
|
||||
backendSession.includes('"diffAvailable": False') &&
|
||||
backendSession.includes('"content": ""') &&
|
||||
backendSession.includes("不生成或伪造 diff")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"frontend service still calls session diff endpoint",
|
||||
frontendApi.includes("getSessionDiff") && frontendApi.includes("/sessions/${sessionId}/diff")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"frontend postMessage bridge emits mnote open-file payload",
|
||||
embed.includes("postMNoteOpenFile") &&
|
||||
embed.includes("type: 'mnote:open-file'") &&
|
||||
embed.includes("source: payload.source || 'openhub-diff'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"DiffViewer exposes changed path open action",
|
||||
diffViewer.includes("postMNoteOpenFile") &&
|
||||
diffViewer.includes("rootRelativePath") &&
|
||||
diffViewer.includes("diffAvailable")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"SmartQueryPage loads changed files after stream and exposes hidden bridge payload",
|
||||
smartQuery.includes("loadChangedFiles(finalConversationId)") &&
|
||||
smartQuery.includes("data-mnote-openhub-changed-file") &&
|
||||
smartQuery.includes("handleOpenChangedFile")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote host listens for OpenHub open-file bridge",
|
||||
mnoteRuntime.includes("pageAiInstallMNoteOpenFileBridge") &&
|
||||
mnoteRuntime.includes("message.type !== 'mnote:open-file'") &&
|
||||
mnoteRuntime.includes("openhub-diff") &&
|
||||
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
|
||||
);
|
||||
|
||||
const liveProbeInput = loadTask774Probe();
|
||||
let liveProbe;
|
||||
try {
|
||||
liveProbe = await probeLiveEndpoint(liveProbeInput);
|
||||
if (!liveProbe.skipped) {
|
||||
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", liveProbe.ok, liveProbe);
|
||||
}
|
||||
} catch (error) {
|
||||
liveProbe = {
|
||||
skipped: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reason: "MNote/OpenHub 服务不可达或 task774 session 已不可读",
|
||||
};
|
||||
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", false, liveProbe);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task776-openhub-changed-files-bridge-smoke",
|
||||
liveProbe,
|
||||
failures,
|
||||
};
|
||||
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "weknora-section-context-smoke-result.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
|
||||
function readTask772Result() {
|
||||
if (!fs.existsSync(TASK772_RESULT_PATH)) {
|
||||
throw new Error(`缺少 task772 结果,请先运行 node scripts/task772-weknora-ingest-search-open-reference-e2e.js: ${TASK772_RESULT_PATH}`);
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8"));
|
||||
assert.equal(payload.ok, true, `task772 结果不是 ok=true: ${JSON.stringify(payload, null, 2).slice(0, 1000)}`);
|
||||
const sourcePath = payload.sourcePath;
|
||||
const providerKnowledgeId = payload.providerKnowledgeId;
|
||||
const providerKnowledgeBaseId = payload.providerKnowledgeBaseId;
|
||||
const providerChunkId = payload.search?.providerIds?.chunkId || payload.openReference?.providerIds?.chunkId;
|
||||
assert(sourcePath, "task772 结果缺少 sourcePath");
|
||||
assert(providerKnowledgeId, "task772 结果缺少 providerKnowledgeId");
|
||||
assert(providerChunkId, "task772 结果缺少 provider chunkId");
|
||||
return {
|
||||
sourcePath,
|
||||
providerKnowledgeId,
|
||||
providerKnowledgeBaseId,
|
||||
providerChunkId,
|
||||
marker: payload.marker,
|
||||
};
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: "mnote-e2e",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function sectionContext(context, body) {
|
||||
const response = await context.post(`${BASE_URL}/api/knowledge-rag/section-context`, {
|
||||
data: {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
maxChars: 4000,
|
||||
...body,
|
||||
},
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
assert(response.ok(), `section-context HTTP ${response.status()}: ${text.slice(0, 1000)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function assertProviderChunkPayload(payload, expected) {
|
||||
assert.equal(payload.provider, "weknora", `provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload.sidecarRead, false, "WeKnora section-context 不应读取 LightRAG sidecar");
|
||||
assert.equal(payload.providerChunkFetch?.attempted, true, "应尝试 WeKnora provider chunk fetch");
|
||||
assert.equal(payload.providerChunkFetch?.ok, true, `provider chunk fetch 应成功: ${JSON.stringify(payload.providerChunkFetch, null, 2)}`);
|
||||
assert(Array.isArray(payload.blocks) && payload.blocks.length > 0, "section-context 应返回 provider chunk blocks");
|
||||
assert(Array.isArray(payload.chunks) && payload.chunks.length > 0, "section-context 应返回 agent chunks");
|
||||
assert(String(payload.text || "").includes(expected.marker), "section-context text 应包含 task772 marker");
|
||||
const first = payload.blocks[0];
|
||||
assert(first.providerChunk, `block 应保留 providerChunk metadata: ${JSON.stringify(first, null, 2).slice(0, 1000)}`);
|
||||
assert.equal(first.providerChunk.knowledgeId, expected.providerKnowledgeId, "providerChunk knowledgeId 应保留");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const expected = readTask772Result();
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
let result = { ok: false, task: "task777-weknora-section-context-smoke", expected };
|
||||
try {
|
||||
await signIn(context);
|
||||
const byChunk = await sectionContext(context, {
|
||||
sourcePath: expected.sourcePath,
|
||||
providerKnowledgeId: expected.providerKnowledgeId,
|
||||
providerKnowledgeBaseId: expected.providerKnowledgeBaseId,
|
||||
providerChunkId: expected.providerChunkId,
|
||||
});
|
||||
assertProviderChunkPayload(byChunk, expected);
|
||||
const byKnowledge = await sectionContext(context, {
|
||||
sourcePath: expected.sourcePath,
|
||||
providerKnowledgeId: expected.providerKnowledgeId,
|
||||
providerKnowledgeBaseId: expected.providerKnowledgeBaseId,
|
||||
});
|
||||
assertProviderChunkPayload(byKnowledge, expected);
|
||||
result = {
|
||||
...result,
|
||||
ok: true,
|
||||
byChunk: {
|
||||
providerChunkFetch: byChunk.providerChunkFetch,
|
||||
providerChunkId: byChunk.providerChunkId,
|
||||
returnedBlocks: byChunk.blocks.length,
|
||||
returnedChunks: byChunk.chunks.length,
|
||||
degradedReason: byChunk.degradedReason,
|
||||
},
|
||||
byKnowledge: {
|
||||
providerChunkFetch: byKnowledge.providerChunkFetch,
|
||||
returnedBlocks: byKnowledge.blocks.length,
|
||||
returnedChunks: byKnowledge.chunks.length,
|
||||
degradedReason: byKnowledge.degradedReason,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const routePath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs");
|
||||
const modPath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/mod.rs");
|
||||
const designPath = path.join(
|
||||
repoRoot,
|
||||
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
|
||||
);
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
const route = read(routePath);
|
||||
const routesMod = read(modPath);
|
||||
const design = read(designPath);
|
||||
const failures = [];
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote exposes artifact index API under Page AI OpenHub boundary",
|
||||
routesMod.includes('"/api/page-ai/openhub/artifact-index"') &&
|
||||
routesMod.includes("get(page_ai_openhub::artifact_index_get).post(page_ai_openhub::artifact_index_upsert)")
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index schema stores only lightweight locator fields",
|
||||
route.includes("mnote.page_ai_openhub_artifact_index_record.v1") &&
|
||||
route.includes('"openhubSessionId"') &&
|
||||
route.includes('"kind"') &&
|
||||
route.includes('"providerId"') &&
|
||||
route.includes('"path"') &&
|
||||
route.includes('"citationPayload"')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index explicitly refuses OpenHub message fulltext fields",
|
||||
route.includes("reject_fulltext_message_fields") &&
|
||||
route.includes("page_ai_openhub_artifact_index_forbidden_fulltext_field") &&
|
||||
route.includes('"message"') &&
|
||||
route.includes('"conversationMessages"') &&
|
||||
route.includes('"assistantMessage"') &&
|
||||
route.includes('"userMessage"') &&
|
||||
route.includes('"content"') &&
|
||||
route.includes('"transcript"')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index response documents no message fulltext copy",
|
||||
route.includes('"messageFulltextCopied": false') &&
|
||||
route.includes('"openhub_message_fulltext"') &&
|
||||
route.includes('"openhub_conversation_message_rows"') &&
|
||||
route.includes('"assistant_text"') &&
|
||||
route.includes('"user_prompt"')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"minimal persistence stays inside MNote/root metadata or explicit env path",
|
||||
route.includes("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") &&
|
||||
route.includes('join(".mnote")') &&
|
||||
route.includes('join("page-ai-openhub-artifact-index.json")') &&
|
||||
route.includes("write_artifact_index_records")
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"design checklist records completed artifact index boundary",
|
||||
design.includes("[x] MNote 只存 artifact index") &&
|
||||
design.includes("task778-openhub-artifact-index-static-smoke.js") &&
|
||||
design.includes("不是复制 OpenHub SQLite message 表")
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task778-openhub-artifact-index-static-smoke",
|
||||
failures,
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh-result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh.png");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath, workspaceId) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function markdown(title, lines) {
|
||||
return ["---", `title: ${title}`, "---", "", ...lines, ""].join("\n");
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, text) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (editor?.textContent || "").includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readDocumentPaneState(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-pane-role="primary"]');
|
||||
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
const aggregateNode = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||
let aggregate = null;
|
||||
try {
|
||||
aggregate = JSON.parse(aggregateNode?.textContent || "null");
|
||||
} catch {}
|
||||
return {
|
||||
paneDocumentId: pane?.getAttribute("data-pane-document-id") || "",
|
||||
runtimeStatus: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
runtimeError: root?.getAttribute("data-runtime-editor-error") || "",
|
||||
editorText: editor?.textContent || "",
|
||||
aggregateText: JSON.stringify(aggregate?.body || aggregate || {}),
|
||||
syncedAt: aggregateNode?.getAttribute("data-mnote-page-aggregate-synced-at") || "",
|
||||
openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "",
|
||||
eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
|
||||
eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
|
||||
documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath, workspaceId) {
|
||||
await page.goto(documentUrl(root, relativePath, workspaceId), { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPageAiRuntime(page) {
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
|
||||
&& typeof window.__mnoteDocumentPaneRuntime?.refreshPrimaryDocument === "function"
|
||||
&& typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === "function",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function installOpenHubChangedFileBridge(page) {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
} catch {}
|
||||
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
|
||||
});
|
||||
await page.locator('[data-testid="wolai-page-ai-drawer"][data-page-ai-openhub-host="true"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function simulateOpenHubChangedFile(page, root, relativePath, workspaceId) {
|
||||
return page.evaluate(({ rootUri, path, workspaceId }) => {
|
||||
window.postMessage({
|
||||
type: "mnote:open-file",
|
||||
source: "openhub-changed-files",
|
||||
path,
|
||||
rootUri,
|
||||
workspaceId,
|
||||
documentId: `local-md:${path.replaceAll("/", "~2F")}`,
|
||||
}, window.location.origin);
|
||||
return true;
|
||||
}, { rootUri: fileUrl(root), path: relativePath, workspaceId });
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task779-openhub-pane-refresh-"));
|
||||
const workspaceId = `local-ws:user_real:task779:${Date.now()}`;
|
||||
const relativePath = "task779-openhub-refresh.md";
|
||||
const initialText = "task779 initial document pane text";
|
||||
const changedText = `task779 openhub changed file bridge ${Date.now()}`;
|
||||
writeWorkspaceManifest(root, "user_real", workspaceId);
|
||||
fs.writeFileSync(path.join(root, relativePath), markdown("Task 779 OpenHub Refresh", [initialText]), "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1360, height: 900 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const debug = { root, workspaceId, relativePath, initialText, changedText };
|
||||
|
||||
try {
|
||||
await openDocument(page, root, relativePath, workspaceId);
|
||||
await waitForEditorText(page, initialText);
|
||||
await waitForPageAiRuntime(page);
|
||||
await installOpenHubChangedFileBridge(page);
|
||||
debug.before = await readDocumentPaneState(page);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, relativePath),
|
||||
markdown("Task 779 OpenHub Refresh", [changedText]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const opened = await simulateOpenHubChangedFile(page, root, relativePath, workspaceId);
|
||||
assert.equal(opened, true, "OpenHub changed-file bridge 应接受当前 Markdown path");
|
||||
await waitForEditorText(page, changedText);
|
||||
debug.after = await readDocumentPaneState(page);
|
||||
assert.equal(debug.after.paneDocumentId, localMdDocumentId(relativePath), "primary document pane 应仍打开测试 Markdown");
|
||||
assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`);
|
||||
assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`);
|
||||
assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker");
|
||||
assert.equal(debug.after.eventBusSource, "openhub_changed_file_bridge", "应复用 local-folder event bus synthetic watch batch");
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task779-openhub-file-edit-document-pane-refresh-smoke",
|
||||
root,
|
||||
relativePath,
|
||||
documentId: localMdDocumentId(relativePath),
|
||||
changedText,
|
||||
beforeText: debug.before.editorText,
|
||||
afterText: debug.after.editorText,
|
||||
eventBusSource: debug.after.eventBusSource,
|
||||
eventBusReason: debug.after.eventBusReason,
|
||||
resultPath: RESULT_PATH,
|
||||
screenshotPath: SCREENSHOT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`ok task779-openhub-file-edit-document-pane-refresh-smoke ${RESULT_PATH}`);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
|
||||
ok: false,
|
||||
error: String(error && error.stack || error),
|
||||
debug,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { TextDecoder } = require("node:util");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
|
||||
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
|
||||
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
|
||||
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
|
||||
const designPath = path.join(
|
||||
repoRoot,
|
||||
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
|
||||
);
|
||||
|
||||
const forbiddenFulltextKeys = [
|
||||
"message",
|
||||
"messages",
|
||||
"messageContent",
|
||||
"conversation",
|
||||
"conversationMessages",
|
||||
"assistantMessage",
|
||||
"userMessage",
|
||||
"content",
|
||||
"text",
|
||||
"transcript",
|
||||
];
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
function base64UrlJson(value) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function hasForbiddenKey(value) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some(hasForbiddenKey);
|
||||
return Object.entries(value).some(([key, entry]) => (
|
||||
forbiddenFulltextKeys.includes(key) || hasForbiddenKey(entry)
|
||||
));
|
||||
}
|
||||
|
||||
async function probeEmbedRuntime(embedSource) {
|
||||
const mnoteScope = {
|
||||
workspaceScope: {
|
||||
rootUri: "file:///tmp/mnote-artifact-root",
|
||||
workspaceId: "ws-task780",
|
||||
pageResourceId: "page-task780",
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
const sandbox = {
|
||||
console,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
Uint8Array,
|
||||
window: {
|
||||
location: {
|
||||
pathname: "/page-ai/openhub/ai",
|
||||
search: `?scope=session-task780&mnoteScope=${base64UrlJson(mnoteScope)}`,
|
||||
origin: "http://127.0.0.1:3000",
|
||||
},
|
||||
parent: {},
|
||||
atob: (value) => Buffer.from(value, "base64").toString("binary"),
|
||||
},
|
||||
fetch: async (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
};
|
||||
},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
};
|
||||
const transformed = embedSource
|
||||
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
|
||||
.replace(/\bexport const /g, "const ");
|
||||
vm.runInNewContext(
|
||||
`${transformed}\nmodule.exports = { getMNoteArtifactIndexContext, postMNoteArtifactIndex };`,
|
||||
sandbox,
|
||||
{ filename: embedPath }
|
||||
);
|
||||
const result = await sandbox.module.exports.postMNoteArtifactIndex({
|
||||
openhubSessionId: "ses-task780",
|
||||
kind: "changed_file",
|
||||
providerId: "opencode_tool_event",
|
||||
path: "/tmp/mnote-artifact-root/page.md",
|
||||
citationPayload: {
|
||||
schema: "openhub.changed_file_locator.v1",
|
||||
rootRelativePath: "page.md",
|
||||
diffAvailable: false,
|
||||
},
|
||||
});
|
||||
const body = calls[0] ? JSON.parse(calls[0].options.body) : null;
|
||||
return { result, calls, body };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const failures = [];
|
||||
const embed = read(embedPath);
|
||||
const smartQuery = read(smartQueryPath);
|
||||
const diffViewer = read(diffViewerPath);
|
||||
const design = read(designPath);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"mnoteEmbed posts artifact index to MNote root API",
|
||||
embed.includes("postMNoteArtifactIndex") &&
|
||||
embed.includes("getMNoteArtifactIndexContext") &&
|
||||
embed.includes("fetch('/api/page-ai/openhub/artifact-index'") &&
|
||||
embed.includes("credentials: 'include'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index payload is limited to locator fields",
|
||||
["openhubSessionId", "kind", "providerId", "path", "citationPayload", "rootUri", "workspaceId", "pageResourceId"]
|
||||
.every((field) => embed.includes(field))
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"SmartQueryPage indexes changed files after diff metadata is loaded",
|
||||
smartQuery.includes("indexChangedFiles(sessionId, files)") &&
|
||||
smartQuery.includes("kind: 'changed_file'") &&
|
||||
smartQuery.includes("schema: 'openhub.changed_file_locator.v1'") &&
|
||||
smartQuery.includes("providerId: file?.source || 'opencode_tool_event'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"SmartQueryPage indexes WeKnora citation locator payloads",
|
||||
smartQuery.includes("indexCitationArtifacts") &&
|
||||
smartQuery.includes("kind: 'citation'") &&
|
||||
smartQuery.includes("schema: 'openhub.weknora_citation_locator.v1'") &&
|
||||
smartQuery.includes("citation?.sourceRootRelativePath")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"citation payload sanitizer strips fulltext-like fields recursively",
|
||||
forbiddenFulltextKeys.every((key) => smartQuery.includes(`'${key}'`)) &&
|
||||
smartQuery.includes("sanitizeCitationPayload(entry)") &&
|
||||
smartQuery.includes(".filter(([key]) => !forbiddenKeys.has(key))")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"changed file bridge remains connected to MNote open-file event",
|
||||
diffViewer.includes("postMNoteOpenFile") &&
|
||||
smartQuery.includes("data-mnote-openhub-changed-file") &&
|
||||
smartQuery.includes("handleOpenChangedFile")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"design checklist records task780 runtime artifact index bridge",
|
||||
design.includes("task780-openhub-artifact-index-runtime-smoke.js") &&
|
||||
design.includes("changed_file / citation 轻量 artifact index")
|
||||
);
|
||||
|
||||
let runtimeProbe = null;
|
||||
try {
|
||||
runtimeProbe = await probeEmbedRuntime(embed);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime request uses MNote artifact-index endpoint",
|
||||
runtimeProbe.calls.length === 1 &&
|
||||
runtimeProbe.calls[0].url === "/api/page-ai/openhub/artifact-index" &&
|
||||
runtimeProbe.calls[0].options.method === "POST",
|
||||
runtimeProbe
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime request body contains required locator fields",
|
||||
runtimeProbe.body &&
|
||||
runtimeProbe.body.openhubSessionId === "ses-task780" &&
|
||||
runtimeProbe.body.kind === "changed_file" &&
|
||||
runtimeProbe.body.providerId === "opencode_tool_event" &&
|
||||
runtimeProbe.body.path === "/tmp/mnote-artifact-root/page.md" &&
|
||||
runtimeProbe.body.rootUri === "file:///tmp/mnote-artifact-root" &&
|
||||
runtimeProbe.body.workspaceId === "ws-task780" &&
|
||||
runtimeProbe.body.pageResourceId === "page-task780",
|
||||
runtimeProbe.body
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime request body does not contain OpenHub message fulltext fields",
|
||||
runtimeProbe.body && !hasForbiddenKey(runtimeProbe.body),
|
||||
runtimeProbe.body
|
||||
);
|
||||
} catch (error) {
|
||||
runtimeProbe = { error: error instanceof Error ? error.stack || error.message : String(error) };
|
||||
assertCheck(failures, "runtime request shape probe executes", false, runtimeProbe);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task780-openhub-artifact-index-runtime-smoke",
|
||||
runtimeProbe,
|
||||
failures,
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const FIXTURE_DIR = process.env.MNOTE_TASK781_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task781-knowledge-bases-registry-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK781_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK781_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
function assertKnowledgeBases(payload) {
|
||||
assert.equal(payload?.provider, "weknora", `status provider 应为 weknora: ${JSON.stringify(payload?.providerConfig || payload, null, 2).slice(0, 1000)}`);
|
||||
assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "status 应返回 mnote_knowledge_bases registry schema");
|
||||
assert.equal(payload?.registry?.schema, "mnote.knowledge_rag.source_registry.v1", "status 应保留旧 source registry schema");
|
||||
assert.equal(payload?.providerConfig?.weknora?.knowledgeBaseRegistry, "mnote_knowledge_bases", "providerConfig 应暴露 KB registry 边界");
|
||||
assert.equal(payload?.providerConfig?.weknora?.sourceRegistry, "mnote_knowledge_sources", "providerConfig 应暴露 source registry 边界");
|
||||
const bases = payload?.knowledgeBases?.bases || [];
|
||||
assert(Array.isArray(bases) && bases.length > 0, `knowledgeBases.bases 应至少包含默认 WeKnora KB: ${JSON.stringify(payload?.knowledgeBases, null, 2)}`);
|
||||
const base = bases.find((item) => item.provider === "weknora" && item.providerKbId);
|
||||
assert(base, `缺少 provider-neutral WeKnora KB: ${JSON.stringify(bases, null, 2)}`);
|
||||
assert.equal(base.defaultToolEnabled, true, "默认 WeKnora KB 应开启 tool 可见性");
|
||||
return base;
|
||||
}
|
||||
|
||||
async function ingest(context, sourcePath) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
sources: [{ sourcePath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_http", `ingest HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "ingest 应返回 knowledgeBases registry");
|
||||
const item = (payload.configuredSources || []).find((source) => source.sourceRootRelativePath === sourcePath);
|
||||
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(payload.configuredSources, null, 2).slice(0, 1000)}`);
|
||||
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应 provider_mapped: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
|
||||
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
|
||||
const registryEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === sourcePath);
|
||||
assert(registryEntry, "source registry 应写入目标 source");
|
||||
assert.equal(registryEntry.provider, "weknora", `source registry 应写 provider: ${JSON.stringify(registryEntry, null, 2)}`);
|
||||
assert.equal(registryEntry.providerKnowledgeBaseId, item.providerKnowledgeBaseId, "source registry 应写 providerKnowledgeBaseId");
|
||||
assert.equal(registryEntry.providerKnowledgeId, item.providerKnowledgeId, "source registry 应写 providerKnowledgeId");
|
||||
assert.equal(registryEntry.providerSourceId, item.providerKnowledgeId, "source registry 应写 providerSourceId");
|
||||
assert(registryEntry.lightRagDocId, "旧 lightRagDocId 兼容字段仍应保留 provider knowledge id");
|
||||
return { payload, item, registryEntry };
|
||||
}
|
||||
|
||||
async function search(context, marker, sourcePath) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query: marker,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
sourcePaths: [sourcePath],
|
||||
});
|
||||
assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora" && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回 task781 source 超时", { marker, sourcePath, lastPayload });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const beforeStatus = await status(context);
|
||||
const baseBefore = assertKnowledgeBases(beforeStatus);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const marker = `task781 knowledge bases registry marker ${startedAt}`;
|
||||
const sourcePath = `${FIXTURE_DIR}/task781-knowledge-base-registry-${startedAt}.md`;
|
||||
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
|
||||
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
absoluteSourcePath,
|
||||
[
|
||||
"# Task 781 Knowledge Base Registry",
|
||||
"",
|
||||
marker,
|
||||
"",
|
||||
"This file verifies MNote provider-neutral knowledge base and source registry boundaries.",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ingestResult = await ingest(context, sourcePath);
|
||||
const searchResult = await search(context, marker, sourcePath);
|
||||
const afterStatus = await status(context);
|
||||
const baseAfter = assertKnowledgeBases(afterStatus);
|
||||
|
||||
const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json");
|
||||
assert(fs.existsSync(persistedRegistryPath), `应持久化 mnote_knowledge_bases registry: ${persistedRegistryPath}`);
|
||||
const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8"));
|
||||
assert.equal(persistedRegistry.schema, "mnote.knowledge_bases.registry.v1", "持久化 KB registry schema 不正确");
|
||||
assert((persistedRegistry.bases || []).some((base) => base.provider === "weknora" && base.providerKbId), "持久化 KB registry 缺少 WeKnora base");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task781-knowledge-bases-registry-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourcePath,
|
||||
absoluteSourcePath,
|
||||
marker,
|
||||
knowledgeBase: {
|
||||
before: {
|
||||
baseId: baseBefore.baseId,
|
||||
provider: baseBefore.provider,
|
||||
providerKbId: baseBefore.providerKbId,
|
||||
defaultToolEnabled: baseBefore.defaultToolEnabled,
|
||||
},
|
||||
after: {
|
||||
baseId: baseAfter.baseId,
|
||||
provider: baseAfter.provider,
|
||||
providerKbId: baseAfter.providerKbId,
|
||||
defaultToolEnabled: baseAfter.defaultToolEnabled,
|
||||
sourceCount: baseAfter.sourceCount,
|
||||
},
|
||||
persistedRegistryPath,
|
||||
},
|
||||
sourceRegistry: {
|
||||
provider: ingestResult.registryEntry.provider,
|
||||
providerStatus: ingestResult.registryEntry.providerStatus,
|
||||
providerKnowledgeBaseId: ingestResult.registryEntry.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: ingestResult.registryEntry.providerKnowledgeId,
|
||||
providerSourceId: ingestResult.registryEntry.providerSourceId,
|
||||
lightRagDocIdCompat: ingestResult.registryEntry.lightRagDocId,
|
||||
},
|
||||
search: {
|
||||
provider: searchResult.payload.provider,
|
||||
referenceProvider: searchResult.reference.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task781-knowledge-bases-registry-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
|
||||
const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py");
|
||||
const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py");
|
||||
const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py");
|
||||
const backendScopePath = path.join(openHubRoot, "smart-query-backend/app/core/mnote_scope.py");
|
||||
const frontendEmbedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
|
||||
const assistantMessagePath = path.join(openHubRoot, "smart-query-frontend/src/components/AssistantMessage.jsx");
|
||||
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
|
||||
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
|
||||
const checklistPath = path.join(repoRoot, "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md");
|
||||
const outputDir = path.join(repoRoot, "tmp", "7-68-runtime");
|
||||
const resultPath = path.join(outputDir, "openhub-weknora-tool-citation-bridge-smoke-result.json");
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
function base64UrlJson(value) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const start = source.indexOf(`export const ${name}`);
|
||||
if (start < 0) throw new Error(`找不到 ${name}`);
|
||||
const next = source.indexOf("\nexport const ", start + 1);
|
||||
return source.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
async function probeEmbedOpenReference(embedSource) {
|
||||
const scope = {
|
||||
workspaceScope: {
|
||||
rootUri: "file:///tmp/mnote-openhub-task782",
|
||||
workspaceId: "ws-task782",
|
||||
pageResourceId: "page-task782",
|
||||
},
|
||||
};
|
||||
const posted = [];
|
||||
const sandbox = {
|
||||
console,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
Uint8Array,
|
||||
window: {
|
||||
location: {
|
||||
pathname: "/page-ai/openhub/ai",
|
||||
search: `?scope=session-task782&mnoteScope=${base64UrlJson(scope)}`,
|
||||
origin: "http://127.0.0.1:3000",
|
||||
},
|
||||
parent: {
|
||||
postMessage: (message, origin) => posted.push({ message, origin }),
|
||||
},
|
||||
atob: (value) => Buffer.from(value, "base64").toString("binary"),
|
||||
},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
};
|
||||
const transformed = embedSource
|
||||
.slice(0, embedSource.indexOf("const compactObject"))
|
||||
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
|
||||
.replace(/\bexport const /g, "const ");
|
||||
vm.runInNewContext(
|
||||
`${transformed}\nmodule.exports = { postMNoteOpenReference };`,
|
||||
sandbox,
|
||||
{ filename: frontendEmbedPath }
|
||||
);
|
||||
const ok = sandbox.module.exports.postMNoteOpenReference({
|
||||
schema: "openhub.weknora_citation_locator.v1",
|
||||
citation: {
|
||||
provider: "weknora",
|
||||
sourceRootRelativePath: "knowledge-rag-fixtures-7-68/task782.md",
|
||||
citationLabel: "task782.md",
|
||||
},
|
||||
});
|
||||
return { ok, posted };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const failures = [];
|
||||
const backendBridge = read(backendBridgePath);
|
||||
const backendApi = read(backendApiPath);
|
||||
const backendStream = read(backendStreamPath);
|
||||
const backendScope = read(backendScopePath);
|
||||
const frontendEmbed = read(frontendEmbedPath);
|
||||
const assistantMessage = read(assistantMessagePath);
|
||||
const smartQuery = read(smartQueryPath);
|
||||
const mnoteRuntime = read(mnoteRuntimePath);
|
||||
const checklist = read(checklistPath);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub backend exposes MNote WeKnora readonly facade endpoint",
|
||||
backendApi.includes("/api/mnote/tools/call") &&
|
||||
backendApi.includes("call_mnote_weknora_tool") &&
|
||||
backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/hermes/tools/mnote/call\"")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"backend tool call carries scope and does not expose WeKnora API key",
|
||||
backendBridge.includes("capabilityScope") &&
|
||||
backendBridge.includes("knowledge_rag.read") &&
|
||||
backendBridge.includes("allowedRoots") &&
|
||||
backendBridge.includes("mnote_cookie") &&
|
||||
!backendBridge.includes("MNOTE_WEKNORA_API_KEY")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub stream auto-bridges KB queries through MNote WeKnora",
|
||||
backendStream.includes("auto_search_for_question") &&
|
||||
backendStream.includes("<mnote_weknora_tool_result>") &&
|
||||
backendStream.includes("_push_mnote_weknora_tool_event") &&
|
||||
backendStream.includes("mnote.weknora.search")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote session cookie is only forwarded as trusted server header",
|
||||
backendScope.includes("X-MNote-Session-Cookie") &&
|
||||
mnoteRuntime.includes("mnote:open-reference") &&
|
||||
!frontendEmbed.includes("mnote_session=")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub frontend renders clickable WeKnora citations",
|
||||
assistantMessage.includes("data-mnote-openhub-citation") &&
|
||||
assistantMessage.includes("openhub.weknora_citation_locator.v1") &&
|
||||
assistantMessage.includes("onOpenMNoteCitation") &&
|
||||
smartQuery.includes("postMNoteOpenReference")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote host accepts citation open-reference postMessage bridge",
|
||||
mnoteRuntime.includes("message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference'") &&
|
||||
mnoteRuntime.includes("openhub-citation") &&
|
||||
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
|
||||
);
|
||||
|
||||
const runtimeProbe = await probeEmbedOpenReference(frontendEmbed);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime postMNoteOpenReference posts mnote:open-reference",
|
||||
runtimeProbe.ok &&
|
||||
runtimeProbe.posted.length === 1 &&
|
||||
runtimeProbe.posted[0].message.type === "mnote:open-reference" &&
|
||||
runtimeProbe.posted[0].message.source === "openhub-citation" &&
|
||||
runtimeProbe.posted[0].message.path.endsWith("task782.md"),
|
||||
runtimeProbe
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"checklist has task782 completion note",
|
||||
checklist.includes("task782-openhub-weknora-tool-citation-bridge-smoke.js")
|
||||
);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task782-openhub-weknora-tool-citation-bridge-smoke",
|
||||
runtimeProbe,
|
||||
failures,
|
||||
};
|
||||
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (failures.length) {
|
||||
console.error(JSON.stringify(result, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task783-weknora-kb-settings-ui-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
|
||||
const SIDEBAR_RUNTIME = "rust/crates/mnote-web/browser/sidebar-tree-runtime.js";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function assertOrdered(content, first, second, label) {
|
||||
const firstIndex = content.indexOf(first);
|
||||
const secondIndex = content.indexOf(second);
|
||||
assert(firstIndex >= 0, `${label} 缺少: ${first}`);
|
||||
assert(secondIndex >= 0, `${label} 缺少: ${second}`);
|
||||
assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应早于 ${second}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const settings = read(SETTINGS_RUNTIME);
|
||||
const sidebar = read(SIDEBAR_RUNTIME);
|
||||
|
||||
for (const required of [
|
||||
"data-mnote-weknora-page-replacement=\"true\"",
|
||||
"mnote-weknora-kb-page",
|
||||
"data-testid=\"mnote-weknora-kb-list-pane\"",
|
||||
"data-testid=\"mnote-weknora-kb-card-list\"",
|
||||
"data-knowledge-rag-action=\"select-kb\"",
|
||||
"data-testid=\"mnote-weknora-kb-detail-pane\"",
|
||||
"data-testid=\"mnote-weknora-kb-detail-hero\"",
|
||||
"data-weknora-document-list-view=\"true\"",
|
||||
"知识库列表",
|
||||
"创建知识库",
|
||||
"上传/索引到 WeKnora",
|
||||
"data-testid=\"mnote-weknora-kb-summary\"",
|
||||
"data-knowledge-rag-kb-provider=\"weknora\"",
|
||||
"data-knowledge-rag-kb-id",
|
||||
"data-knowledge-rag-kb-ids",
|
||||
"data-knowledge-rag-processing-count",
|
||||
"data-knowledge-rag-source-picker=\"mnote-filetree\"",
|
||||
"data-knowledge-rag-source-kind=\"local-file-or-folder\"",
|
||||
"本地文件或文件夹,例如 docs/book.pdf / docs",
|
||||
"providerKnowledgeBaseId",
|
||||
"providerKnowledgeId",
|
||||
"provider_mapped",
|
||||
"data-knowledge-rag-kb-id",
|
||||
"data-knowledge-rag-chunk-id",
|
||||
"data-kb-rag-search-result=\"true\"",
|
||||
"data-knowledge-rag-action=\"open-source-reference\"",
|
||||
"data-knowledge-rag-open-reference-mode=\"search-result-open-reference\"",
|
||||
"data-mnote-knowledge-rag-open-reference-mode",
|
||||
"source-registry-reference",
|
||||
"openPrimaryDocument",
|
||||
"resourcePath",
|
||||
"删除 provider index 与 registry 映射,不删除本地文件",
|
||||
]) {
|
||||
assertContains(settings, required, SETTINGS_RUNTIME);
|
||||
}
|
||||
|
||||
assertContains(sidebar, "openKnowledgeRagSourceReference", SIDEBAR_RUNTIME);
|
||||
assertContains(sidebar, "knowledgeRagActionName === 'open-source-reference'", SIDEBAR_RUNTIME);
|
||||
assertContains(sidebar, "knowledgeRagActionName === 'select-kb'", SIDEBAR_RUNTIME);
|
||||
assertContains(sidebar, "knowledgeRagActionName === 'focus-create-kb'", SIDEBAR_RUNTIME);
|
||||
|
||||
assertOrdered(
|
||||
settings,
|
||||
"knowledgeRagKbSummaryHtml(status, entries, providerDocs)",
|
||||
"<div><span>Provider</span><code>",
|
||||
"KB summary 应显示在 provider 详情前",
|
||||
);
|
||||
assertOrdered(
|
||||
settings,
|
||||
"data-knowledge-rag-action=\"open-source-reference\"",
|
||||
"data-knowledge-rag-action=\"delete-source\"",
|
||||
"source row 应先提供打开引用,再提供删除索引",
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [SETTINGS_RUNTIME, SIDEBAR_RUNTIME],
|
||||
assertions: {
|
||||
kbSummaryVisible: true,
|
||||
weknoraPageReplacementVisible: true,
|
||||
weknoraKbListPaneVisible: true,
|
||||
weknoraKbDetailPaneVisible: true,
|
||||
weknoraDocumentListViewVisible: true,
|
||||
fileTreePickerBoundary: true,
|
||||
processingStateVisible: true,
|
||||
searchResultOpenReference: true,
|
||||
sourceRowOpenReference: true,
|
||||
deletePreservesLocalFileCopy: true,
|
||||
noBackendKnowledgeRagEditRequired: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task784-weknora-kb-settings-browser-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-settings.png");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function loadTask772Result() {
|
||||
assert(fs.existsSync(TASK772_RESULT_PATH), `缺少 task772 真实入库结果: ${TASK772_RESULT_PATH}`);
|
||||
return JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8"));
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const fixture = loadTask772Result();
|
||||
assert.equal(fixture.provider, "weknora", `task772 provider 不是 weknora: ${JSON.stringify(fixture, null, 2).slice(0, 1200)}`);
|
||||
assert(fixture.rootUri && fixture.workspaceId && fixture.sourcePath && fixture.marker, "task772 结果缺少 rootUri/workspaceId/sourcePath/marker");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await signIn(context);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fixture.rootUri);
|
||||
url.searchParams.set("workspaceId", fixture.workspaceId);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const sourceRow = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first();
|
||||
await sourceRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await sourceRow.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await sourceRow.locator('[data-knowledge-rag-action="delete-source"][data-knowledge-rag-delete-local-file-policy="preserve-local-file"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
|
||||
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
|
||||
assert(String(kbIds || "").includes(fixture.providerKnowledgeBaseId), `KB summary 未包含 task772 KB id: ${kbIds}`);
|
||||
|
||||
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-search-input]').fill(fixture.marker, { timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const searchResult = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first();
|
||||
await searchResult.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await searchResult.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const result = {
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: fixture.rootUri,
|
||||
workspaceId: fixture.workspaceId,
|
||||
sourcePath: fixture.sourcePath,
|
||||
providerKnowledgeBaseId: fixture.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: fixture.providerKnowledgeId,
|
||||
kbSummaryVisible: true,
|
||||
sourceRowVisible: true,
|
||||
searchResultVisible: true,
|
||||
openReferenceControlsVisible: true,
|
||||
deletePreserveLocalFileControlVisible: true,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "failure.json"),
|
||||
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.error(error.stack || error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task785-weknora-create-kb-folder-ingest-search-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK785_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK785_WEKNORA_POLL_MS || 5_000);
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function createKnowledgeBase(context, name, description) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name,
|
||||
description,
|
||||
defaultToolEnabled: true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
|
||||
assert.equal(payload?.schema, "mnote.knowledge_rag.create_knowledge_base_result.v1", "create KB schema 不正确");
|
||||
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
|
||||
assert.equal(payload?.knowledgeBase?.defaultToolEnabled, true, "新 KB 应默认启用 tool");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
providerKnowledgeBaseId,
|
||||
sources: [{ sourcePath: folderPath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
const configured = payload.configuredSources || [];
|
||||
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1200)}`);
|
||||
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
|
||||
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
|
||||
const registryEntries = payload.registry?.entries || [];
|
||||
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
|
||||
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
|
||||
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
|
||||
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
|
||||
return { payload, configured, mapped, folderEntries };
|
||||
}
|
||||
|
||||
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
providerKnowledgeBaseId,
|
||||
sourcePaths: [folderPath],
|
||||
});
|
||||
assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const pathText = String(reference.sourceRootRelativePath || "");
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
|
||||
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
|
||||
query,
|
||||
folderPath,
|
||||
providerKnowledgeBaseId,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signIn(context);
|
||||
const startedAt = Date.now();
|
||||
const markerA = `task785 create kb folder marker alpha ${startedAt}`;
|
||||
const markerB = `task785 create kb folder marker beta ${startedAt}`;
|
||||
const folderPath = `knowledge-rag-fixtures-7-68/task785-folder-${startedAt}`;
|
||||
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
|
||||
fs.mkdirSync(absoluteFolderPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(absoluteFolderPath, "alpha.md"), `# Task 785 Alpha\n\n${markerA}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(absoluteFolderPath, "beta.md"), `# Task 785 Beta\n\n${markerB}\n`, "utf8");
|
||||
|
||||
const name = `task785-weknora-kb-${startedAt}`;
|
||||
const createResult = await createKnowledgeBase(context, name, "MNote task785 temporary KB created through MNote facade");
|
||||
const providerKnowledgeBaseId = createResult.providerKnowledgeBaseId;
|
||||
const ingestResult = await ingestFolder(context, folderPath, providerKnowledgeBaseId);
|
||||
const searchResult = await searchUntilHit(context, markerA, folderPath, providerKnowledgeBaseId);
|
||||
const afterStatus = await status(context);
|
||||
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
|
||||
assert(statusBase, "status knowledgeBases 应包含新建 KB");
|
||||
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
|
||||
|
||||
const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json");
|
||||
const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8"));
|
||||
assert((persistedRegistry.bases || []).some((base) => base.providerKbId === providerKnowledgeBaseId), "持久化 KB registry 应包含新建 KB");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task785-weknora-create-kb-folder-ingest-search-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
folderPath,
|
||||
absoluteFolderPath,
|
||||
markers: [markerA, markerB],
|
||||
knowledgeBase: {
|
||||
name,
|
||||
providerKnowledgeBaseId,
|
||||
baseId: createResult.knowledgeBase?.baseId,
|
||||
defaultToolEnabled: createResult.knowledgeBase?.defaultToolEnabled,
|
||||
sourceCount: statusBase.sourceCount,
|
||||
status: statusBase.status,
|
||||
},
|
||||
ingest: {
|
||||
configuredCount: ingestResult.configured.length,
|
||||
mappedCount: ingestResult.mapped.length,
|
||||
sourcePaths: ingestResult.mapped.map((item) => item.sourceRootRelativePath),
|
||||
},
|
||||
search: {
|
||||
provider: searchResult.payload.provider,
|
||||
referenceProvider: searchResult.reference.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
persistedRegistryPath,
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task785-weknora-create-kb-folder-ingest-search-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task786-openhub-history-refresh-browser-smoke";
|
||||
const BASE_URL = (process.env.BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_STREAM_TIMEOUT_MS || 45_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task786-openhub-history-refresh-browser-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "openhub-history-refresh.png");
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(layer, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.layer = layer;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonSnippet(value, limit = 800) {
|
||||
if (typeof value === "string") return value.slice(0, limit);
|
||||
return JSON.stringify(value, null, 2).slice(0, limit);
|
||||
}
|
||||
|
||||
function openHubProxyUrl(pathname, scopeQuery, extraParams = {}) {
|
||||
const url = new URL(`${BASE_URL}/page-ai/openhub/ai${pathname}`);
|
||||
const scopeParams = new URLSearchParams(String(scopeQuery || "").replace(/^\?/, ""));
|
||||
for (const [key, value] of scopeParams.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response, layer, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new SmokeFailure(layer, `${label} 返回非 JSON:HTTP ${response.status()}`, {
|
||||
status: response.status(),
|
||||
bodySnippet: text.slice(0, 800),
|
||||
});
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new SmokeFailure(layer, `${label} 失败:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function assertMNoteReachable() {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${BASE_URL}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("mnote_service", `MNote 3000 服务不可达:${error.message}`, { baseUrl: BASE_URL });
|
||||
}
|
||||
if (!response.ok && response.status !== 303) {
|
||||
throw new SmokeFailure("mnote_service", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl: BASE_URL });
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(requestContext) {
|
||||
const response = await requestContext.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: TEST_EMAIL,
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await parseJsonResponse(response, "auth", "/api/auth 登录");
|
||||
const whoami = await requestContext.get(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS });
|
||||
const viewer = await parseJsonResponse(whoami, "auth", "/api/auth/whoami");
|
||||
if (!viewer || !viewer.userId) {
|
||||
throw new SmokeFailure("auth", "登录后 whoami 缺少 userId", { loginPayload: payload, whoami: viewer });
|
||||
}
|
||||
return viewer;
|
||||
}
|
||||
|
||||
async function visibleAny(page, selectors, label) {
|
||||
await page.waitForFunction((candidateSelectors) => {
|
||||
const visible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return candidateSelectors.some((selector) => Array.from(document.querySelectorAll(selector)).some(visible));
|
||||
}, selectors, { timeout: UI_TIMEOUT_MS }).catch((error) => {
|
||||
throw new SmokeFailure("mnote_page_ai_ui", `${label} 不可见`, { selectors, cause: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
async function openMNoteOpenHubDrawer(page) {
|
||||
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth", "打开 MNote 主页后仍被重定向到 /auth", { url: page.url() });
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
await page.waitForFunction(() => (
|
||||
typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
|
||||
|| document.querySelector("[data-testid='wolai-floating-ai']")
|
||||
|| document.querySelector("[data-testid='wolai-page-ai-drawer']")
|
||||
), null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const openedByRuntime = await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
if (typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function") {
|
||||
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!openedByRuntime) {
|
||||
const floating = page.locator("[data-testid='wolai-floating-ai']").first();
|
||||
if (await floating.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await floating.click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
await visibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
|
||||
await page.waitForFunction(() => {
|
||||
const drawer = document.querySelector("[data-testid='wolai-page-ai-drawer']");
|
||||
return drawer instanceof HTMLElement && drawer.getAttribute("data-page-ai-openhub-host") === "true";
|
||||
}, null, { timeout: UI_TIMEOUT_MS }).catch((error) => {
|
||||
throw new SmokeFailure("mnote_page_ai_ui", "Page AI drawer 未切到 OpenHub host", {
|
||||
cause: error.message,
|
||||
drawerText: "",
|
||||
});
|
||||
});
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
return iframe instanceof HTMLIFrameElement && (iframe.getAttribute("src") || "").includes("/page-ai/openhub/ai");
|
||||
}, null, { timeout: UI_TIMEOUT_MS }).catch((error) => {
|
||||
throw new SmokeFailure("mnote_page_ai_ui", "OpenHub iframe 未挂载或 src 未指向 /page-ai/openhub/ai", { cause: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
async function getOpenHubFrame(page) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let lastState = null;
|
||||
while (Date.now() < deadline) {
|
||||
const handle = await page.locator("iframe[data-page-ai-openhub-iframe]").first().elementHandle().catch(() => null);
|
||||
if (handle) {
|
||||
const frame = await handle.contentFrame();
|
||||
lastState = {
|
||||
iframeSrc: await handle.getAttribute("src").catch(() => ""),
|
||||
frameUrl: frame ? frame.url() : "",
|
||||
};
|
||||
if (frame && frame.url().includes("/page-ai/openhub/ai")) {
|
||||
await frame.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
throw new SmokeFailure("openhub_iframe_ui", "无法取得 OpenHub iframe frame", lastState || {});
|
||||
}
|
||||
|
||||
async function collectFrameState(frame, marker) {
|
||||
return frame.evaluate((expectedMarker) => {
|
||||
const text = (selector) => (document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const bodyText = (document.body?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const drawerText = Array.from(document.querySelectorAll(".ant-drawer, [role='dialog']"))
|
||||
.map((node) => (node.textContent || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const messageAreaText = text(".messages-area");
|
||||
const historyButtons = Array.from(document.querySelectorAll("button"))
|
||||
.map((button) => (button.textContent || button.getAttribute("title") || button.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
bodySnippet: bodyText.slice(0, 1200),
|
||||
iframeShellKind: document.body?.getAttribute("data-mnote-openhub-ai-shell") || "",
|
||||
hasReactRoot: Boolean(document.getElementById("root")),
|
||||
hasOpenHubTitle: bodyText.includes("OpenHub 平台"),
|
||||
hasHistoryButton: historyButtons.some((entry) => entry.includes("历史记录")),
|
||||
historyButtons,
|
||||
drawerTextSnippet: drawerText.slice(0, 1200),
|
||||
messageAreaSnippet: messageAreaText.slice(0, 1200),
|
||||
bodyHasMarker: bodyText.includes(expectedMarker),
|
||||
drawerHasMarker: drawerText.includes(expectedMarker),
|
||||
messageAreaHasMarker: messageAreaText.includes(expectedMarker),
|
||||
staticBoundaryVisible: /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(bodyText),
|
||||
loginVisible: /(登录\s*OpenHub|OpenHub\s*Login|Sign in to OpenHub|WeKnora\s*登录)/i.test(bodyText),
|
||||
};
|
||||
}, marker);
|
||||
}
|
||||
|
||||
function scopeQueryFromFrameUrl(frameUrl) {
|
||||
const url = new URL(frameUrl, BASE_URL);
|
||||
const query = url.searchParams.toString();
|
||||
if (!query || !url.searchParams.get("scope") || !url.searchParams.get("mnoteScope")) {
|
||||
throw new SmokeFailure("openhub_scope", "OpenHub iframe URL 缺少 scope/mnoteScope", { frameUrl });
|
||||
}
|
||||
return `?${query}`;
|
||||
}
|
||||
|
||||
function parseStreamEvents(streamText) {
|
||||
const eventTypes = [];
|
||||
let returnedSessionId = "";
|
||||
let errorEvent = "";
|
||||
for (const line of String(streamText || "").split(/\r?\n/)) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const payload = data.payload && data.payload.type
|
||||
? { type: data.payload.type, ...data.payload.properties }
|
||||
: data;
|
||||
if (payload.type) eventTypes.push(payload.type);
|
||||
if (payload.conversation_id) returnedSessionId = payload.conversation_id;
|
||||
if (payload.error) errorEvent = String(payload.error);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
eventTypes: [...new Set(eventTypes)],
|
||||
returnedSessionId,
|
||||
errorEvent,
|
||||
snippet: String(streamText || "").slice(0, 1200),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMarkerMessage(requestContext, scopeQuery, sessionId, marker) {
|
||||
const prompt = `${marker} Page AI OpenHub history refresh smoke. 请只回复 ${marker},不要解释。`;
|
||||
const body = {
|
||||
question: prompt,
|
||||
conversation_id: sessionId,
|
||||
agent: "build",
|
||||
};
|
||||
const url = openHubProxyUrl("/api/query/stream", scopeQuery);
|
||||
try {
|
||||
const response = await requestContext.post(url, {
|
||||
data: body,
|
||||
headers: { "content-type": "application/json" },
|
||||
timeout: STREAM_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
return {
|
||||
ok: response.ok(),
|
||||
status: response.status(),
|
||||
prompt,
|
||||
...parseStreamEvents(text),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
prompt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessagesUntilMarker(requestContext, scopeQuery, sessionId, marker) {
|
||||
let lastPayload = null;
|
||||
let lastStatus = 0;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const response = await requestContext.get(openHubProxyUrl(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, scopeQuery), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
lastStatus = response.status();
|
||||
const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") }));
|
||||
lastPayload = payload;
|
||||
const messages = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const userMessage = messages.find((message) => message.role === "user" && String(message.content || "").includes(marker));
|
||||
if (response.ok() && userMessage) {
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status(),
|
||||
count: messages.length,
|
||||
roles: messages.map((message) => message.role),
|
||||
userMessageContent: String(userMessage.content || ""),
|
||||
lastMessages: messages.slice(-3).map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").slice(0, 300),
|
||||
created_at: message.created_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new SmokeFailure("openhub_api_messages", "OpenHub API 未读回带 marker 的 user message", {
|
||||
sessionId,
|
||||
marker,
|
||||
status: lastStatus,
|
||||
payloadSnippet: jsonSnippet(lastPayload),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSessionsUntilMarker(requestContext, scopeQuery, sessionId, marker) {
|
||||
let lastPayload = null;
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const response = await requestContext.get(openHubProxyUrl("/api/sessions", scopeQuery, { page: 1, page_size: 10 }), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") }));
|
||||
lastPayload = payload;
|
||||
const sessions = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const found = sessions.find((session) => String(session.session_id || session.id || "") === sessionId);
|
||||
const markerTitle = sessions.find((session) => String(session.title || "").includes(marker));
|
||||
if (response.ok() && (found || markerTitle)) {
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status(),
|
||||
total: payload?.pagination?.total,
|
||||
foundSession: found || markerTitle,
|
||||
markerInTitle: Boolean(markerTitle),
|
||||
firstSessions: sessions.slice(0, 5).map((session) => ({
|
||||
session_id: session.session_id || session.id || "",
|
||||
title: session.title || "",
|
||||
updated_at: session.updated_at || "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new SmokeFailure("openhub_api_history", "OpenHub sessions API 未读回对应 session/history", {
|
||||
sessionId,
|
||||
marker,
|
||||
payloadSnippet: jsonSnippet(lastPayload),
|
||||
});
|
||||
}
|
||||
|
||||
async function assertOpenHubReactUsable(frame, marker) {
|
||||
const state = await collectFrameState(frame, marker);
|
||||
if (state.staticBoundaryVisible) {
|
||||
throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 仍是静态边界页,不能把 API-only 记为 UI 通过", state);
|
||||
}
|
||||
if (state.loginVisible) {
|
||||
throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 出现独立登录入口", state);
|
||||
}
|
||||
if (!state.hasOpenHubTitle && !state.hasHistoryButton) {
|
||||
throw new SmokeFailure("openhub_iframe_ui", "OpenHub React AI 面板未渲染出历史入口", state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openHistoryAndVerify(frame, sessionId, marker) {
|
||||
let beforeHistoryState = await collectFrameState(frame, marker);
|
||||
if (beforeHistoryState.messageAreaHasMarker) {
|
||||
return {
|
||||
uiMarkerVerified: true,
|
||||
historyMarkerVerified: false,
|
||||
currentMessageMarkerVerified: true,
|
||||
beforeHistoryState,
|
||||
afterHistoryState: beforeHistoryState,
|
||||
afterSessionClickState: beforeHistoryState,
|
||||
};
|
||||
}
|
||||
|
||||
let historyButton = frame.getByRole("button", { name: /历史记录/ }).first();
|
||||
if (!(await historyButton.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
||||
historyButton = frame.locator('button[title="历史记录"]').first();
|
||||
}
|
||||
if (!(await historyButton.isVisible({ timeout: 8_000 }).catch(() => false))) {
|
||||
throw new SmokeFailure("openhub_history_ui", "OpenHub iframe 内未找到历史记录按钮", beforeHistoryState);
|
||||
}
|
||||
await historyButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
let afterHistoryState = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
afterHistoryState = await collectFrameState(frame, marker);
|
||||
if (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker) break;
|
||||
if (attempt === 2) {
|
||||
const refreshButton = frame.getByRole("button", { name: /刷新/ }).first();
|
||||
if (await refreshButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await refreshButton.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await frame.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const historyMarkerVerified = Boolean(afterHistoryState && (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker));
|
||||
if (!historyMarkerVerified) {
|
||||
throw new SmokeFailure("openhub_history_ui", "刷新后 OpenHub history/session UI 未显示 marker/session 标题", {
|
||||
sessionId,
|
||||
marker,
|
||||
beforeHistoryState,
|
||||
afterHistoryState,
|
||||
});
|
||||
}
|
||||
|
||||
const markerText = frame.getByText(marker, { exact: false }).first();
|
||||
if (await markerText.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await markerText.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
|
||||
let afterSessionClickState = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
afterSessionClickState = await collectFrameState(frame, marker);
|
||||
if (afterSessionClickState.messageAreaHasMarker) break;
|
||||
await frame.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
return {
|
||||
uiMarkerVerified: historyMarkerVerified || Boolean(afterSessionClickState?.messageAreaHasMarker),
|
||||
historyMarkerVerified,
|
||||
currentMessageMarkerVerified: Boolean(afterSessionClickState?.messageAreaHasMarker),
|
||||
beforeHistoryState,
|
||||
afterHistoryState,
|
||||
afterSessionClickState,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const marker = `TASK786_MARKER_${suffix}`;
|
||||
const sessionId = `task786-openhub-history-${suffix}`;
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
let result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
sessionId,
|
||||
marker,
|
||||
screenshotPath: SCREENSHOT_PATH,
|
||||
uiMarker: "",
|
||||
historyMarker: "",
|
||||
uiMarkerVerified: false,
|
||||
historyMarkerVerified: false,
|
||||
currentMessageMarkerVerified: false,
|
||||
};
|
||||
|
||||
try {
|
||||
await assertMNoteReachable();
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
page = await context.newPage();
|
||||
|
||||
const viewer = await signIn(context.request);
|
||||
await openMNoteOpenHubDrawer(page);
|
||||
let frame = await getOpenHubFrame(page);
|
||||
const initialFrameState = await assertOpenHubReactUsable(frame, marker);
|
||||
const scopeQuery = scopeQueryFromFrameUrl(frame.url());
|
||||
|
||||
const sendStream = await sendMarkerMessage(context.request, scopeQuery, sessionId, marker);
|
||||
const apiMessages = await fetchMessagesUntilMarker(context.request, scopeQuery, sessionId, marker);
|
||||
const apiSessions = await fetchSessionsUntilMarker(context.request, scopeQuery, sessionId, marker);
|
||||
|
||||
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await openMNoteOpenHubDrawer(page);
|
||||
frame = await getOpenHubFrame(page);
|
||||
const refreshedFrameState = await assertOpenHubReactUsable(frame, marker);
|
||||
const ui = await openHistoryAndVerify(frame, sessionId, marker);
|
||||
|
||||
if (!ui.uiMarkerVerified) {
|
||||
throw new SmokeFailure("openhub_history_ui", "API 已读回 marker,但刷新后 UI 未恢复 marker/session", {
|
||||
sessionId,
|
||||
marker,
|
||||
ui,
|
||||
});
|
||||
}
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
result = {
|
||||
...result,
|
||||
ok: true,
|
||||
viewer,
|
||||
scopeQueryKeys: Array.from(new URLSearchParams(scopeQuery.slice(1)).keys()),
|
||||
sendStream,
|
||||
apiMessageVerified: true,
|
||||
apiHistoryVerified: true,
|
||||
apiMessages,
|
||||
apiSessions,
|
||||
initialFrameState,
|
||||
refreshedFrameState,
|
||||
uiMarkerVerified: ui.uiMarkerVerified,
|
||||
historyMarkerVerified: ui.historyMarkerVerified,
|
||||
currentMessageMarkerVerified: ui.currentMessageMarkerVerified,
|
||||
uiMarker: ui.currentMessageMarkerVerified ? marker : "",
|
||||
historyMarker: ui.historyMarkerVerified ? marker : "",
|
||||
beforeHistoryState: ui.beforeHistoryState,
|
||||
afterHistoryState: ui.afterHistoryState,
|
||||
afterSessionClickState: ui.afterSessionClickState,
|
||||
resultPath: RESULT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(FAILURE_PATH)) fs.rmSync(FAILURE_PATH, { force: true });
|
||||
} catch (error) {
|
||||
if (page) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
failureLayer: error instanceof SmokeFailure ? error.layer : "unexpected",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
details: error instanceof SmokeFailure ? error.details : undefined,
|
||||
failurePath: FAILURE_PATH,
|
||||
};
|
||||
fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(RESULT_PATH)) fs.rmSync(RESULT_PATH, { force: true });
|
||||
} finally {
|
||||
if (context) await context.close().catch(() => undefined);
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const failure = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
failureLayer: "fatal",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
failurePath: FAILURE_PATH,
|
||||
};
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(failure, null, 2)}\n`, "utf8");
|
||||
console.error(JSON.stringify(failure, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task787-weknora-default-lightrag-legacy-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_TASK787_STATUS_TIMEOUT_MS || 15_000);
|
||||
|
||||
const REQUIRED_STATIC_FILES = [
|
||||
"rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js",
|
||||
"rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js",
|
||||
"scripts/TESTING_REFERENCE.md",
|
||||
];
|
||||
|
||||
const TASK_SMOKE_PATTERN = /^task(?:53|54|76|78)\d.*\.js$/;
|
||||
const SELF_PATH = "scripts/task787-weknora-default-lightrag-legacy-smoke.js";
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function listTaskSmokeFiles() {
|
||||
return fs.readdirSync(path.join(ROOT, "scripts"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && TASK_SMOKE_PATTERN.test(entry.name))
|
||||
.map((entry) => `scripts/${entry.name}`)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function extractWindow(line, start, length) {
|
||||
const from = Math.max(0, start - 90);
|
||||
const to = Math.min(line.length, start + length + 90);
|
||||
return line.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function allowedLegacyContext(contextBlock, occurrenceWindow) {
|
||||
const lowerBlock = contextBlock.toLowerCase();
|
||||
const lowerWindow = occurrenceWindow.toLowerCase();
|
||||
const legacyMarkers = [
|
||||
"legacy",
|
||||
"fallback",
|
||||
"history",
|
||||
"historical",
|
||||
"debug",
|
||||
"compat",
|
||||
"retired",
|
||||
"assertnotcontains",
|
||||
"assertnotincludes",
|
||||
"forbidden",
|
||||
"notcontains",
|
||||
"notincludes",
|
||||
"not contain",
|
||||
"does not include",
|
||||
"no lightrag",
|
||||
"nolightrag",
|
||||
"nodefaultlightrag",
|
||||
"without lightrag",
|
||||
"bypass",
|
||||
"bypasses",
|
||||
"skip",
|
||||
"旧",
|
||||
"历史",
|
||||
"兼容",
|
||||
"调试",
|
||||
"退役",
|
||||
"软归档",
|
||||
"不应包含",
|
||||
"不作为",
|
||||
"不再",
|
||||
"不直接",
|
||||
"不删除",
|
||||
"不走",
|
||||
"禁止",
|
||||
"绕过",
|
||||
"跳过",
|
||||
"移除",
|
||||
"缺少",
|
||||
];
|
||||
return legacyMarkers.some((marker) => lowerWindow.includes(marker) || lowerBlock.includes(marker));
|
||||
}
|
||||
|
||||
function ignoredTechnicalField(line) {
|
||||
return /\blightRag(?:DocId|FilePath|Status)\b/.test(line)
|
||||
|| /lightrag-source-registry\.json/.test(line);
|
||||
}
|
||||
|
||||
function ignoredLegacyAssetName(line) {
|
||||
return /task\d+-lightrag-[\w-]+/.test(line);
|
||||
}
|
||||
|
||||
function ignoredNegativeAssertionName(line) {
|
||||
return /\b(?:uiNoDefault|ingestNo|deleteNo|sectionContextNo|noDefault)LightRag[A-Za-z0-9_]*\b/.test(line);
|
||||
}
|
||||
|
||||
function classifyActiveLightRagContext(contextBlock, occurrenceWindow) {
|
||||
const normalized = `${contextBlock}\n${occurrenceWindow}`.toLowerCase();
|
||||
const reasons = [];
|
||||
const activeDefaultPatterns = [
|
||||
/默认\s*provider/i,
|
||||
/唯一默认/i,
|
||||
/主线\s*provider/i,
|
||||
/default\s*provider/i,
|
||||
/primary\s*provider/i,
|
||||
/mainline\s*provider/i,
|
||||
/only\s*default/i,
|
||||
];
|
||||
if (activeDefaultPatterns.some((pattern) => pattern.test(contextBlock) || pattern.test(occurrenceWindow))) {
|
||||
reasons.push("forbidden_default_or_mainline_provider_copy");
|
||||
}
|
||||
const activeSurfacePatterns = [
|
||||
/资料库/,
|
||||
/知识库/,
|
||||
/问答/,
|
||||
/检索/,
|
||||
/搜索/,
|
||||
/引用/,
|
||||
/入库/,
|
||||
/dashboard/,
|
||||
/health/,
|
||||
/status/,
|
||||
/source[-_ ]?registry/,
|
||||
/search\s*provider/,
|
||||
/references?/,
|
||||
/retrieval/,
|
||||
/smoke/,
|
||||
];
|
||||
if (activeSurfacePatterns.some((pattern) => pattern.test(normalized))) {
|
||||
reasons.push("active_lightrag_surface_without_legacy_marker");
|
||||
}
|
||||
if (reasons.length === 0) {
|
||||
reasons.push("unqualified_lightrag_reference_without_legacy_marker");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function scanStaticFiles(files) {
|
||||
const violations = [];
|
||||
const scannedFiles = [];
|
||||
for (const relativePath of files) {
|
||||
const absolutePath = path.join(ROOT, relativePath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
violations.push({
|
||||
file: relativePath,
|
||||
line: 0,
|
||||
reason: "static_file_missing",
|
||||
context: "required static/smoke baseline file is missing",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
scannedFiles.push(relativePath);
|
||||
const content = fs.readFileSync(absolutePath, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
const matches = Array.from(line.matchAll(/lightrag/gi));
|
||||
for (const match of matches) {
|
||||
if (ignoredTechnicalField(line) || ignoredLegacyAssetName(line) || ignoredNegativeAssertionName(line)) {
|
||||
continue;
|
||||
}
|
||||
const occurrenceWindow = extractWindow(line, match.index || 0, match[0].length);
|
||||
const contextBlock = lines.slice(Math.max(0, index - 8), Math.min(lines.length, index + 5)).join("\n");
|
||||
if (allowedLegacyContext(contextBlock, occurrenceWindow)) {
|
||||
continue;
|
||||
}
|
||||
violations.push({
|
||||
file: relativePath,
|
||||
line: index + 1,
|
||||
reason: classifyActiveLightRagContext(contextBlock, occurrenceWindow),
|
||||
context: occurrenceWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return { scannedFiles, violations };
|
||||
}
|
||||
|
||||
async function fetchStatusProviderConfig() {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const url = `${BASE_URL}/api/knowledge-rag/status`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
layer: "status_api_invalid_json",
|
||||
url,
|
||||
status: response.status,
|
||||
error: error.message,
|
||||
rawText: text.slice(0, 1000),
|
||||
providerConfig: null,
|
||||
};
|
||||
}
|
||||
const providerConfig = payload && typeof payload.providerConfig === "object" ? payload.providerConfig : null;
|
||||
const active = providerConfig && providerConfig.active;
|
||||
const defaultProvider = providerConfig && providerConfig.default;
|
||||
const provider = payload && payload.provider;
|
||||
const ok = response.ok && providerConfig && active === "weknora" && defaultProvider === "weknora";
|
||||
return {
|
||||
ok,
|
||||
layer: response.ok ? (ok ? "status_provider_config_ok" : "status_provider_config_mismatch") : "status_api_http_error",
|
||||
url,
|
||||
status: response.status,
|
||||
provider,
|
||||
providerConfig,
|
||||
responseSample: ok ? undefined : payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
layer: error && error.name === "AbortError" ? "status_api_timeout" : "status_api_unavailable",
|
||||
url,
|
||||
error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error),
|
||||
providerConfig: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const staticFiles = uniqueSorted([...REQUIRED_STATIC_FILES, ...listTaskSmokeFiles()]);
|
||||
const staticScan = scanStaticFiles(staticFiles.filter((relativePath) => relativePath !== SELF_PATH));
|
||||
const statusCheck = await fetchStatusProviderConfig();
|
||||
const ok = staticScan.violations.length === 0 && statusCheck.ok;
|
||||
const payload = {
|
||||
ok,
|
||||
task: "task787-weknora-default-lightrag-legacy-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
checkedAt: new Date().toISOString(),
|
||||
scannedFiles: staticScan.scannedFiles,
|
||||
violations: staticScan.violations,
|
||||
statusProviderConfig: statusCheck,
|
||||
};
|
||||
if (ok) {
|
||||
writeJson(RESULT_PATH, payload);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task787-weknora-default-lightrag-legacy-smoke",
|
||||
layer: "unexpected",
|
||||
error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error),
|
||||
baseUrl: BASE_URL,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium, request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task788-weknora-kb-folder-browser-flow-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-folder-browser-flow.png");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK788_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK788_WEKNORA_POLL_MS || 5_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signInRequest(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function signInBrowser(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `浏览器上下文登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_status_http", `status HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function createKnowledgeBase(context, name, description) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name,
|
||||
description,
|
||||
defaultToolEnabled: true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
|
||||
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
providerKnowledgeBaseId,
|
||||
sources: [{ sourcePath: folderPath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
|
||||
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
|
||||
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
|
||||
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 2000)}`);
|
||||
const registryEntries = payload.registry?.entries || [];
|
||||
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
|
||||
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
|
||||
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
|
||||
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
|
||||
return { payload, configured, mapped, folderEntries };
|
||||
}
|
||||
|
||||
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
providerKnowledgeBaseId,
|
||||
sourcePaths: [folderPath],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_search_http", `search HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const pathText = String(reference.sourceRootRelativePath || "");
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
|
||||
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
|
||||
query,
|
||||
folderPath,
|
||||
providerKnowledgeBaseId,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function createFolderFixture(startedAt) {
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
const marker = `task788 weknora kb folder browser marker ${startedAt}`;
|
||||
const markers = [`${marker} alpha`, `${marker} beta`];
|
||||
const folderPath = `knowledge-rag-fixtures-7-68/task788-folder-${startedAt}`;
|
||||
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
|
||||
const sourcePaths = [`${folderPath}/alpha.md`, `${folderPath}/beta.md`];
|
||||
fs.mkdirSync(absoluteFolderPath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(absoluteFolderPath, "alpha.md"),
|
||||
`# Task 788 Alpha\n\n${markers[0]}\n\nWeKnora folder browser flow alpha note.\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(absoluteFolderPath, "beta.md"),
|
||||
`# Task 788 Beta\n\n${markers[1]}\n\nWeKnora folder browser flow beta note.\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { folderPath, absoluteFolderPath, sourcePaths, marker, markers };
|
||||
}
|
||||
|
||||
async function verifyInBrowser(fixture, name) {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await signInBrowser(context);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", ROOT_URI);
|
||||
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider");
|
||||
|
||||
await panel.locator('[data-testid="mnote-weknora-create-kb-name"]').fill(name);
|
||||
await panel.locator('[data-testid="mnote-weknora-create-kb-description"]').fill("MNote task788 temporary KB created through browser settings UI");
|
||||
await panel.locator('[data-knowledge-rag-action="create-kb"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const kbSelect = panel.locator('[data-testid="mnote-weknora-kb-select"]');
|
||||
await page.waitForFunction(
|
||||
(expectedName) => {
|
||||
const status = document.querySelector('[data-testid="mnote-weknora-create-kb-status"]');
|
||||
if (!(status instanceof HTMLElement) || status.getAttribute("data-status") !== "done") return false;
|
||||
const select = document.querySelector('[data-testid="mnote-weknora-kb-select"]');
|
||||
if (!(select instanceof HTMLSelectElement) || !select.value.trim()) return false;
|
||||
const selected = select.options[select.selectedIndex];
|
||||
return selected && selected.textContent.includes(expectedName);
|
||||
},
|
||||
name,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const providerKnowledgeBaseId = await kbSelect.inputValue();
|
||||
assert(providerKnowledgeBaseId, "UI 创建 KB 后应选中新 providerKnowledgeBaseId");
|
||||
|
||||
const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first();
|
||||
await firstSourceInput.fill(fixture.folderPath);
|
||||
await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => {
|
||||
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS });
|
||||
await row.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return sourcePath;
|
||||
}));
|
||||
|
||||
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
|
||||
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
|
||||
const sourceRowsForKb = await panel.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).count();
|
||||
assert(sourceRowsForKb >= 2, `source rows 未反映新建 KB id: ${providerKnowledgeBaseId}`);
|
||||
|
||||
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-search-input]').fill(fixture.markers[0]);
|
||||
let searchResultSourcePath = null;
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const result = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).first();
|
||||
try {
|
||||
await result.waitFor({ state: "visible", timeout: POLL_INTERVAL_MS });
|
||||
searchResultSourcePath = await result.getAttribute("data-knowledge-rag-source-path");
|
||||
break;
|
||||
} catch {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
assert(searchResultSourcePath, `UI 检索未展示新 KB ${providerKnowledgeBaseId} 的结果`);
|
||||
await panel.locator('[data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
return {
|
||||
providerKnowledgeBaseId,
|
||||
kbSummaryVisible: true,
|
||||
kbSummaryConfiguredKbIds: String(kbIds || "").split(",").filter(Boolean),
|
||||
sourceRowsForKb,
|
||||
sourceRowsVisible: sourceRows,
|
||||
searchResultVisible: true,
|
||||
searchResultSourcePath,
|
||||
searchVerifiedByApi: false,
|
||||
searchUiProviderKbScopeMissing: false,
|
||||
openReferenceControlsVisible: true,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
};
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
throw failLayer("browser_ui_verify", error.message || String(error), { screenshot: SCREENSHOT_PATH });
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signInRequest(context);
|
||||
const startedAt = Date.now();
|
||||
const fixture = createFolderFixture(startedAt);
|
||||
const name = `task788-weknora-kb-${startedAt}`;
|
||||
const browserResult = await verifyInBrowser(fixture, name);
|
||||
const providerKnowledgeBaseId = browserResult.providerKnowledgeBaseId;
|
||||
const searchResult = await searchUntilHit(context, fixture.markers[0], fixture.folderPath, providerKnowledgeBaseId);
|
||||
const afterStatus = await status(context);
|
||||
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
|
||||
assert(statusBase, "status knowledgeBases 应包含新建 KB");
|
||||
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task788-weknora-kb-folder-browser-flow-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
uiCreatesKb: true,
|
||||
uiAddsFolder: true,
|
||||
browserVerifiesKb: true,
|
||||
browserVerifiesSearch: true,
|
||||
browserVerifiesOpenReferenceControls: true,
|
||||
apiVerifiesSearch: true,
|
||||
folderPath: fixture.folderPath,
|
||||
absoluteFolderPath: fixture.absoluteFolderPath,
|
||||
markers: fixture.markers,
|
||||
knowledgeBase: {
|
||||
name,
|
||||
providerKnowledgeBaseId,
|
||||
baseId: statusBase.baseId,
|
||||
defaultToolEnabled: statusBase.defaultToolEnabled,
|
||||
sourceCount: statusBase.sourceCount,
|
||||
status: statusBase.status,
|
||||
},
|
||||
ingest: {
|
||||
configuredCount: fixture.sourcePaths.length,
|
||||
mappedCount: fixture.sourcePaths.length,
|
||||
sourcePaths: fixture.sourcePaths,
|
||||
},
|
||||
apiSearch: {
|
||||
provider: searchResult.payload.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
browser: browserResult,
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task788-weknora-kb-folder-browser-flow-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task789-weknora-kb-page-ui-reference-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const REFERENCE_DOC = "design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle) {
|
||||
assert(content.includes(needle), `reference doc 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
assert(fs.existsSync(path.join(ROOT, REFERENCE_DOC)), `${REFERENCE_DOC} 不存在`);
|
||||
|
||||
const doc = read(REFERENCE_DOC);
|
||||
for (const required of [
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBaseList.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBase.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/DocumentListView.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/KbUploadSourceDropdown.vue",
|
||||
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/components/knowledge-processing-timeline.vue",
|
||||
"providerKnowledgeId",
|
||||
"不能直接接管的边界",
|
||||
"MNote 最小呈现建议",
|
||||
]) {
|
||||
assertContains(doc, required);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [REFERENCE_DOC],
|
||||
assertions: {
|
||||
referenceDocExists: true,
|
||||
weknoraKbListPathRecorded: true,
|
||||
weknoraKbDetailPathRecorded: true,
|
||||
documentListPathRecorded: true,
|
||||
uploadDropdownPathRecorded: true,
|
||||
processingTimelinePathRecorded: true,
|
||||
mnoteMappingRecorded: true,
|
||||
boundariesRecorded: true,
|
||||
minimumStructureRecorded: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OUTPUT_DIR = path.join(ROOT, "tmp", "task790-weknora-kb-page-experience-static-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
|
||||
const MAIN_CSS = "rust/crates/mnote-web/src/ssr/styles/components/main.css";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(content, needle, label) {
|
||||
assert(content.includes(needle), `${label} 缺少: ${needle}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const runtime = read(SETTINGS_RUNTIME);
|
||||
const css = read(MAIN_CSS);
|
||||
|
||||
for (const required of [
|
||||
"data-mnote-weknora-page-replacement=\"true\"",
|
||||
"data-testid=\"mnote-weknora-kb-list-pane\"",
|
||||
"data-testid=\"mnote-weknora-kb-card-list\"",
|
||||
"data-testid=\"mnote-weknora-kb-breadcrumb\"",
|
||||
"data-testid=\"mnote-weknora-kb-detail-pane\"",
|
||||
"data-testid=\"mnote-weknora-tab-documents\"",
|
||||
"data-testid=\"mnote-weknora-tab-wiki\"",
|
||||
"data-testid=\"mnote-weknora-tab-graph\"",
|
||||
"data-testid=\"mnote-weknora-doc-source-sidebar\"",
|
||||
"data-testid=\"mnote-weknora-doc-source-tags\"",
|
||||
"data-testid=\"mnote-weknora-doc-tags\"",
|
||||
"data-testid=\"mnote-weknora-doc-filter-input\"",
|
||||
"data-testid=\"mnote-weknora-view-switch\"",
|
||||
"data-knowledge-rag-view=\"grid\"",
|
||||
"添加文件夹",
|
||||
"添加文档",
|
||||
"data-weknora-document-list-view=\"true\"",
|
||||
"data-knowledge-rag-processing-status",
|
||||
"data-knowledge-rag-action=\"open-source-reference\"",
|
||||
"data-knowledge-rag-open-reference-mode=\"source-registry-reference\"",
|
||||
"data-testid=\"mnote-weknora-wiki-panel\"",
|
||||
"data-testid=\"mnote-weknora-graph-panel\"",
|
||||
"MNote source registry / WeKnora KB chunk index",
|
||||
]) {
|
||||
assertContains(runtime, required, SETTINGS_RUNTIME);
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
".mnote-weknora-kb-breadcrumb",
|
||||
".mnote-weknora-doc-layout",
|
||||
".mnote-weknora-doc-sidebar",
|
||||
".mnote-weknora-doc-source-tags",
|
||||
".mnote-weknora-view-switch",
|
||||
".mnote-weknora-doc-table[data-weknora-document-view=\"grid\"]",
|
||||
".mnote-weknora-placeholder-panel",
|
||||
".mnote-weknora-source-aux",
|
||||
]) {
|
||||
assertContains(css, required, MAIN_CSS);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
checkedFiles: [SETTINGS_RUNTIME, MAIN_CSS],
|
||||
assertions: {
|
||||
kbListPageVisible: true,
|
||||
kbDetailBreadcrumbVisible: true,
|
||||
documentsWikiGraphTabsVisible: true,
|
||||
sourceAndTagSidebarVisible: true,
|
||||
searchAndFilterVisible: true,
|
||||
addDocumentAndFolderEntrypointsVisible: true,
|
||||
gridListSwitchVisible: true,
|
||||
processingStatusVisible: true,
|
||||
sourceRowsKeepCitationOpenReference: true,
|
||||
mnoteWeKnoraBoundaryPreserved: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
|
||||
const files = {
|
||||
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
|
||||
embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"),
|
||||
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
|
||||
queryModel: path.join(openHubRoot, "smart-query-backend/app/models/query.py"),
|
||||
queryApi: path.join(openHubRoot, "smart-query-backend/app/api/query.py"),
|
||||
stream: path.join(openHubRoot, "smart-query-backend/app/services/stream.py"),
|
||||
mnoteRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
|
||||
};
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed) {
|
||||
if (!passed) failures.push(name);
|
||||
}
|
||||
|
||||
const chatInput = read(files.chatInput);
|
||||
const embed = read(files.embed);
|
||||
const api = read(files.api);
|
||||
const queryModel = read(files.queryModel);
|
||||
const queryApi = read(files.queryApi);
|
||||
const stream = read(files.stream);
|
||||
const mnoteRoute = read(files.mnoteRoute);
|
||||
const failures = [];
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub ChatInput natively owns MNote context toggles",
|
||||
chatInput.includes("requestMNoteActiveTabAddress") &&
|
||||
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
chatInput.includes("data-mnote-openhub-current-folder-toggle") &&
|
||||
chatInput.includes("mnoteContextMode === 'tab'") &&
|
||||
chatInput.includes("mnoteContextMode === 'folder'") &&
|
||||
chatInput.includes("handleSendWithMNoteContext")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"toggles live beside model quota controls and do not write textarea",
|
||||
chatInput.indexOf("model?.monthlyLimit") < chatInput.indexOf("data-mnote-openhub-current-tab-toggle") &&
|
||||
chatInput.includes("handleSend(undefined, context ?") &&
|
||||
!chatInput.includes("setQuestion(context.value") &&
|
||||
!chatInput.includes("setQuestion(mnoteContext")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub mnoteEmbed requests active tab/folder from MNote host",
|
||||
embed.includes("export const requestMNoteActiveTabAddress") &&
|
||||
embed.includes("mnote:get-active-tab-address") &&
|
||||
embed.includes("mnote:active-tab-address") &&
|
||||
embed.includes("kind === 'folder' ? 'folder' : 'tab'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub request body carries hidden mnote_context",
|
||||
api.includes("mnoteContext = null") &&
|
||||
api.includes("requestBody.mnote_context = mnoteContext")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub backend accepts and forwards mnote_context",
|
||||
queryModel.includes("mnote_context: Optional[dict]") &&
|
||||
queryApi.includes("mnote_context=request.mnote_context") &&
|
||||
stream.includes("mnote_context: Optional[dict] = None")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub stream prepends hidden current page/folder context before prompt",
|
||||
stream.includes("<mnote_current_context>") &&
|
||||
stream.includes("当前文件夹") &&
|
||||
stream.includes("当前页面") &&
|
||||
stream.includes("context_parts.append") &&
|
||||
stream.includes("_build_mnote_context_block") &&
|
||||
stream.includes("_mnote_context_from_scope") &&
|
||||
stream.includes("Effective MNote context") &&
|
||||
stream.includes("Sent prompt preview")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote proxy no longer injects floating quick action overlay",
|
||||
!mnoteRoute.includes("data-mnote-openhub-ai-quick-actions") &&
|
||||
!mnoteRoute.includes("mnote_openhub_bridge_markup") &&
|
||||
!mnoteRoute.includes("insertAddress(")
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("OpenHub native MNote context static smoke failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OpenHub native MNote context static smoke passed.");
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot =
|
||||
process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/tmp/mnote-openhub-research/OpenHub";
|
||||
|
||||
const files = {
|
||||
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
|
||||
smartQueryPage: path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"),
|
||||
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
|
||||
openHubFrontendSrc: path.join(openHubRoot, "smart-query-frontend/src"),
|
||||
mnoteOpenHubRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
|
||||
};
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function walkFiles(dir, result = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(fullPath, result);
|
||||
} else {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractBetween(source, startNeedle, endNeedle) {
|
||||
const start = source.indexOf(startNeedle);
|
||||
if (start < 0) return "";
|
||||
const end = source.indexOf(endNeedle, start);
|
||||
return source.slice(start, end < 0 ? undefined : end);
|
||||
}
|
||||
|
||||
function check(failures, name, passed, detail = "") {
|
||||
if (!passed) {
|
||||
failures.push(detail ? `${name}: ${detail}` : name);
|
||||
}
|
||||
}
|
||||
|
||||
const chatInput = read(files.chatInput);
|
||||
const smartQueryPage = read(files.smartQueryPage);
|
||||
const api = read(files.api);
|
||||
const controlRow = extractBetween(chatInput, "<Segmented", "<TextArea");
|
||||
const handleMNoteSend = extractBetween(chatInput, "const handleSendWithMNoteContext", "useEffect(() =>");
|
||||
const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress");
|
||||
|
||||
const legacyFloatingSelectors = [
|
||||
"mnote-openhub-context-bar",
|
||||
"mnote-openhub-native-context",
|
||||
"data-mnote-openhub-ai-quick-actions",
|
||||
"mnote_openhub_bridge_markup",
|
||||
"insertAddress(",
|
||||
];
|
||||
|
||||
const frontendLegacyHits = [];
|
||||
for (const file of walkFiles(files.openHubFrontendSrc)) {
|
||||
const rel = path.relative(openHubRoot, file);
|
||||
const source = read(file);
|
||||
for (const selector of legacyFloatingSelectors) {
|
||||
if (source.includes(selector)) {
|
||||
frontendLegacyHits.push(`${rel} -> ${selector}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mnoteRoute = fs.existsSync(files.mnoteOpenHubRoute) ? read(files.mnoteOpenHubRoute) : "";
|
||||
const mnoteRouteLegacyHits = legacyFloatingSelectors.filter((selector) =>
|
||||
mnoteRoute.includes(selector)
|
||||
);
|
||||
|
||||
const failures = [];
|
||||
|
||||
check(
|
||||
failures,
|
||||
"ChatInput owns two native MNote context buttons",
|
||||
chatInput.includes("isMNoteOpenHubEmbed()") &&
|
||||
chatInput.includes("requestMNoteActiveTabAddress") &&
|
||||
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
chatInput.includes("data-mnote-openhub-current-folder-toggle"),
|
||||
"missing embed gate, context request helper, or native button data attributes"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"MNote context buttons are in the controls row beside agent/model controls",
|
||||
controlRow.includes("<Segmented") &&
|
||||
controlRow.includes("<ModelSelect") &&
|
||||
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
|
||||
controlRow.indexOf("<Segmented") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
|
||||
"native buttons must be before TextArea and in the same row as agent segmented/model select"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"Icon-only buttons use tooltips and aria labels for current page/tab and folder",
|
||||
/Tooltip\s+title=["{][^"'}]*(当前(?:页面|激活\s*Tab|Tab)|current\s*(?:page|tab))/i.test(controlRow) &&
|
||||
/Tooltip\s+title=["{][^"'}]*(当前(?:文件夹|激活\s*Tab\s*的文件夹)|current\s*folder|folder)/i.test(controlRow) &&
|
||||
/aria-label="当前页面"/.test(controlRow) &&
|
||||
/aria-label="文件夹"/.test(controlRow) &&
|
||||
/icon=\{<FileTextOutlined\s*\/>\}/.test(controlRow) &&
|
||||
/icon=\{<FolderOpenOutlined\s*\/>\}/.test(controlRow) &&
|
||||
!/>当前页面<\/Button>/.test(controlRow) &&
|
||||
!/>文件夹<\/Button>/.test(controlRow),
|
||||
"expected two icon-only buttons with distinct tooltip and aria labels"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"ChatInput sends mnote_context as hidden metadata instead of writing URL into textarea question",
|
||||
/handleSend\(undefined,\s*context\s*\?\s*\{/.test(handleMNoteSend) &&
|
||||
handleMNoteSend.includes("kind: mnoteContextMode") &&
|
||||
handleMNoteSend.includes("value: context.value") &&
|
||||
!/setQuestion\s*\(\s*context\./.test(handleMNoteSend) &&
|
||||
!/setQuestion\s*\(\s*mnoteContext/.test(handleMNoteSend) &&
|
||||
!/question\s*=\s*context\.value/.test(handleMNoteSend),
|
||||
"context must flow through handleSend second argument and must not mutate the visible question value"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"SmartQueryPage accepts mnote_context and forwards it to queryDataService.queryDataStream",
|
||||
/const\s+handleSend\s*=\s*async\s*\(\s*overrideQuestion\s*,\s*mnoteContext\s*=\s*null\s*\)/.test(
|
||||
smartQueryPage
|
||||
) &&
|
||||
handleSend.includes("queryDataService.queryDataStream(") &&
|
||||
/abortControllerRef\.current\.signal\s*,\s*mnoteContext/.test(handleSend),
|
||||
"handleSend must accept mnoteContext and pass it through the stream send call"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"OpenHub send body carries hidden mnote_context",
|
||||
/queryDataStream:\s*async\s*\([^)]*mnoteContext\s*=\s*null/.test(api) &&
|
||||
api.includes("requestBody.mnote_context = mnoteContext") &&
|
||||
!/question\s*:\s*.*mnoteContext/.test(api),
|
||||
"api.js must put mnoteContext into requestBody.mnote_context, not append it to question"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"OpenHub frontend no longer depends on legacy MNote floating button selectors",
|
||||
frontendLegacyHits.length === 0,
|
||||
frontendLegacyHits.join("; ")
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"MNote OpenHub route no longer injects legacy floating context buttons",
|
||||
mnoteRouteLegacyHits.length === 0,
|
||||
mnoteRouteLegacyHits.join("; ")
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("OpenHub native MNote context source smoke failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OpenHub native MNote context source smoke passed.");
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const http = require("node:http");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend";
|
||||
const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096);
|
||||
const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181);
|
||||
const SESSION_ID = "ses_mnote_context_fullchain";
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk) => { data += chunk; });
|
||||
req.on("end", () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
function sseEvent(type, properties) {
|
||||
return `data: ${JSON.stringify({ payload: { type, properties } })}\n\n`;
|
||||
}
|
||||
|
||||
function startFakeOpencode() {
|
||||
const capturedPrompts = [];
|
||||
let eventResponse = null;
|
||||
function sendEvents() {
|
||||
if (!eventResponse) return false;
|
||||
eventResponse.write(sseEvent("message.updated", {
|
||||
sessionID: SESSION_ID,
|
||||
info: { id: "msg_assistant", role: "assistant", sessionID: SESSION_ID },
|
||||
}));
|
||||
eventResponse.write(sseEvent("message.part.updated", {
|
||||
sessionID: SESSION_ID,
|
||||
part: { id: "prt_text", messageID: "msg_assistant", sessionID: SESSION_ID, type: "text", text: "OK" },
|
||||
}));
|
||||
eventResponse.write(sseEvent("session.status", {
|
||||
sessionID: SESSION_ID,
|
||||
status: { type: "idle" },
|
||||
}));
|
||||
eventResponse.end();
|
||||
eventResponse = null;
|
||||
return true;
|
||||
}
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${OPENCODE_PORT}`);
|
||||
if (req.method === "GET" && url.pathname === "/global/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/session") {
|
||||
await readBody(req);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ id: SESSION_ID }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/global/event") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
eventResponse = res;
|
||||
eventResponse.write(`data: ${JSON.stringify({ payload: { type: "ready", properties: { sessionID: SESSION_ID } } })}\n\n`);
|
||||
if (capturedPrompts.length) {
|
||||
setTimeout(sendEvents, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === `/session/${SESSION_ID}/prompt_async`) {
|
||||
const body = JSON.parse(await readBody(req) || "{}");
|
||||
capturedPrompts.push(body);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
setTimeout(sendEvents, 50);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not_found", path: url.pathname }));
|
||||
});
|
||||
return {
|
||||
capturedPrompts,
|
||||
listen: () => new Promise((resolve) => server.listen(OPENCODE_PORT, "127.0.0.1", resolve)),
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHealth(url, timeoutMs = 20_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
||||
if (response.status < 500) return;
|
||||
} catch {}
|
||||
await wait(250);
|
||||
}
|
||||
throw new Error(`等待服务超时: ${url}`);
|
||||
}
|
||||
|
||||
async function sendOpenHub(payload, extraHeaders = {}) {
|
||||
const body = JSON.stringify(payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port: OPENHUB_PORT,
|
||||
path: "/api/query/stream",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
"x-mnote-user-key": "openhub_user_test",
|
||||
"x-mnote-workspace-key": "workspace_test",
|
||||
"x-mnote-session-scope": `session_${Date.now()}_${Math.random().toString(16).slice(2)}`,
|
||||
"x-mnote-root-uri": "file:///tmp/mnote-fullchain-workspace",
|
||||
"x-mnote-page-resource-id": "local-md:Inbox~2FPage.md",
|
||||
...extraHeaders,
|
||||
},
|
||||
}, (res) => {
|
||||
let text = "";
|
||||
const timeout = setTimeout(() => {
|
||||
req.destroy();
|
||||
resolve(text);
|
||||
}, 10_000);
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
if (text.includes("message_complete")) {
|
||||
clearTimeout(timeout);
|
||||
req.destroy();
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
res.on("end", () => {
|
||||
clearTimeout(timeout);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`OpenHub HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
|
||||
} else {
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (error.code === "ECONNRESET") return;
|
||||
reject(error);
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fakeOpencode = startFakeOpencode();
|
||||
let uvicorn = null;
|
||||
let stderr = "";
|
||||
await fakeOpencode.listen();
|
||||
try {
|
||||
uvicorn = spawn(".venv/bin/uvicorn", ["app.main:app", "--host", "127.0.0.1", "--port", String(OPENHUB_PORT)], {
|
||||
cwd: OPENHUB_BACKEND_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_BASE_URL: `http://localhost:${OPENCODE_PORT}`,
|
||||
MNOTE_OPENHUB_TOOL_BRIDGE_AUTO: "0",
|
||||
SQLITE_DB_PATH: "/tmp/openhub-fullchain-smoke.db",
|
||||
NO_PROXY: "127.0.0.1,localhost",
|
||||
no_proxy: "127.0.0.1,localhost",
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
uvicorn.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
||||
await waitForHealth(`http://127.0.0.1:${OPENHUB_PORT}/api/health`);
|
||||
|
||||
const tabUrl = "http://127.0.0.1:3000/documents/local-md:Inbox~2FPage.md?sourceKind=local_folder&rootUri=file:///tmp/mnote-fullchain-workspace";
|
||||
await sendOpenHub({
|
||||
question: "只回答 OK",
|
||||
conversation_id: "",
|
||||
agent: "build",
|
||||
mnote_context: {
|
||||
kind: "tab",
|
||||
value: tabUrl,
|
||||
tabUrl,
|
||||
rootUri: "file:///tmp/mnote-fullchain-workspace",
|
||||
documentId: "local-md:Inbox~2FPage.md",
|
||||
relativePath: "Inbox/Page.md",
|
||||
},
|
||||
});
|
||||
await wait(200);
|
||||
await sendOpenHub({ question: "只回答 OK fallback", conversation_id: "", agent: "build" });
|
||||
await wait(200);
|
||||
|
||||
const explicitPrompt = fakeOpencode.capturedPrompts[0]?.parts?.[0]?.text || "";
|
||||
const fallbackPrompt = fakeOpencode.capturedPrompts[1]?.parts?.[0]?.text || "";
|
||||
const result = {
|
||||
ok: false,
|
||||
promptCount: fakeOpencode.capturedPrompts.length,
|
||||
explicitContextOk: explicitPrompt.includes("<mnote_current_context>")
|
||||
&& explicitPrompt.includes("当前页面: http://127.0.0.1:3000/documents/")
|
||||
&& explicitPrompt.includes("relativePath: Inbox/Page.md"),
|
||||
fallbackContextOk: fallbackPrompt.includes("<mnote_current_context>")
|
||||
&& fallbackPrompt.includes("source: mnote_scope_fallback")
|
||||
&& fallbackPrompt.includes("file:///tmp/mnote-fullchain-workspace"),
|
||||
explicitPromptPreview: explicitPrompt.slice(0, 700),
|
||||
fallbackPromptPreview: fallbackPrompt.slice(0, 700),
|
||||
};
|
||||
result.ok = result.promptCount >= 2 && result.explicitContextOk && result.fallbackContextOk;
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
} finally {
|
||||
if (uvicorn) {
|
||||
uvicorn.kill("SIGTERM");
|
||||
await wait(400);
|
||||
}
|
||||
await fakeOpencode.close();
|
||||
if (process.exitCode) process.stderr.write(stderr.slice(-3000));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task79x-weknora-kb-page-browser-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-page-browser-smoke.png");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function locatorCount(page, selector) {
|
||||
return page.locator(selector).count();
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function visible(page, selector) {
|
||||
return page.locator(selector).first().isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
async function visibleCount(page, selector) {
|
||||
return page.locator(selector).evaluateAll((nodes) => nodes.filter((node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
|
||||
}).length).catch(() => 0);
|
||||
}
|
||||
|
||||
async function collectPageState(page) {
|
||||
const panelSelector = '[data-testid="mnote-weknora-knowledge-settings-panel"]';
|
||||
const panel = page.locator(panelSelector);
|
||||
const panelText = await panel.innerText({ timeout: UI_TIMEOUT_MS }).catch(() => "");
|
||||
const activeTab = await page.locator('[data-kb-rag-tab][aria-selected="true"]').first().getAttribute("data-kb-rag-tab").catch(() => "");
|
||||
const metaAttrs = await page.locator('[data-testid="mnote-knowledge-rag-meta"]').evaluate((node) => ({
|
||||
activeProvider: node.getAttribute("data-knowledge-rag-active-provider") || "",
|
||||
legacyFallbackProvider: node.getAttribute("data-knowledge-rag-legacy-fallback-provider") || "",
|
||||
referenceBoundary: node.getAttribute("data-knowledge-rag-reference-boundary") || "",
|
||||
})).catch(() => ({ activeProvider: "", legacyFallbackProvider: "", referenceBoundary: "" }));
|
||||
const firstCard = page.locator('[data-testid="mnote-weknora-kb-card-list"] [data-knowledge-rag-action="select-kb"]').first();
|
||||
const firstSourceRow = page.locator('[data-knowledge-rag-source-row="true"]').first();
|
||||
const firstSearchResult = page.locator('[data-kb-rag-search-result="true"]').first();
|
||||
|
||||
return {
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ""),
|
||||
replacementMarker: await visible(page, '[data-mnote-weknora-page-replacement="true"]'),
|
||||
defaultProvider: await panel.getAttribute("data-knowledge-rag-default-provider").catch(() => ""),
|
||||
providerIndex: await panel.getAttribute("data-knowledge-rag-provider-index").catch(() => ""),
|
||||
sourceRegistry: await panel.getAttribute("data-knowledge-rag-source-registry").catch(() => ""),
|
||||
panelTextSample: panelText.slice(0, 1200),
|
||||
activeTab,
|
||||
metaAttrs,
|
||||
counts: {
|
||||
kbCards: await locatorCount(page, '[data-testid="mnote-weknora-kb-card-list"] [data-knowledge-rag-action="select-kb"]'),
|
||||
createKbControls: await visibleCount(page, '[data-testid="mnote-weknora-kb-controls"], [data-knowledge-rag-action="create-kb"]'),
|
||||
detailHeroes: await visibleCount(page, '[data-testid="mnote-weknora-kb-detail-hero"]'),
|
||||
tabs: await locatorCount(page, '[data-kb-rag-tab]'),
|
||||
sourcePlaceholdersOrRows: await locatorCount(page, '[data-testid="mnote-knowledge-rag-sources"] .wolai-page-settings-index-empty, [data-knowledge-rag-source-row="true"]'),
|
||||
sourceFilters: await locatorCount(page, '[data-testid="mnote-knowledge-rag-source-filters"] [data-knowledge-rag-action="filter-sources"]'),
|
||||
addDocumentEntries: await visibleCount(page, '[data-knowledge-rag-action="add-source"], [data-knowledge-rag-action="ingest"], [data-knowledge-rag-source-input]'),
|
||||
processingStatusControls: await locatorCount(page, '[data-knowledge-rag-processing-count], [data-knowledge-rag-processing-status], [role="progressbar"], [data-testid="mnote-knowledge-rag-status"]'),
|
||||
openReferenceControls: await locatorCount(page, '[data-knowledge-rag-action="open-source-reference"], [data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]'),
|
||||
searchControls: await visibleCount(page, '[data-testid="mnote-weknora-doc-filter-input"], [data-kb-rag-search-input], [data-kb-rag-action="search"]'),
|
||||
oldSimplePanelSignals: await locatorCount(page, '.mnote-knowledge-rag-settings-panel:not(.mnote-weknora-kb-page), [data-testid="mnote-knowledge-rag-settings-panel"]:not([data-mnote-weknora-page-replacement="true"])'),
|
||||
},
|
||||
firstCard: {
|
||||
kbId: await firstCard.getAttribute("data-knowledge-rag-kb-id").catch(() => ""),
|
||||
processingCount: await firstCard.getAttribute("data-knowledge-rag-processing-count").catch(() => ""),
|
||||
text: await firstCard.innerText().catch(() => ""),
|
||||
},
|
||||
firstSourceRow: {
|
||||
sourcePath: await firstSourceRow.getAttribute("data-knowledge-rag-source-path").catch(() => ""),
|
||||
kbId: await firstSourceRow.getAttribute("data-knowledge-rag-kb-id").catch(() => ""),
|
||||
processingStatus: await firstSourceRow.getAttribute("data-knowledge-rag-processing-status").catch(() => ""),
|
||||
openReferenceMode: await firstSourceRow.getAttribute("data-knowledge-rag-open-reference-mode").catch(() => ""),
|
||||
text: await firstSourceRow.innerText().catch(() => ""),
|
||||
},
|
||||
firstSearchResult: {
|
||||
sourcePath: await firstSearchResult.getAttribute("data-knowledge-rag-source-path").catch(() => ""),
|
||||
kbId: await firstSearchResult.getAttribute("data-knowledge-rag-kb-id").catch(() => ""),
|
||||
text: await firstSearchResult.innerText().catch(() => ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateAssertions(state) {
|
||||
const assertions = {
|
||||
openedReplacementPanel: state.replacementMarker === true,
|
||||
providerIsWeKnora: state.defaultProvider === "weknora" && state.metaAttrs.activeProvider === "weknora",
|
||||
notOldSimpleSettingsPanel: state.counts.oldSimplePanelSignals === 0,
|
||||
hasKnowledgeBaseListCards: state.counts.kbCards > 0 || Boolean(state.firstCard.kbId),
|
||||
hasCreateKbControl: state.counts.createKbControls >= 2,
|
||||
hasDetailStructure: state.counts.detailHeroes > 0,
|
||||
hasDocumentTabs: state.counts.tabs >= 3 && /(Documents|Wiki|Graph|文档)/.test(state.panelTextSample),
|
||||
hasSourceOrTagSidebarPlaceholder: state.counts.sourcePlaceholdersOrRows > 0 && state.counts.sourceFilters >= 4,
|
||||
hasSearchAndFilter: state.counts.searchControls >= 1 && state.counts.sourceFilters >= 4,
|
||||
hasAddDocumentEntry: state.counts.addDocumentEntries >= 3,
|
||||
hasProcessingOrStatus: state.counts.processingStatusControls > 0 && /Processing|索引|provider 状态|就绪|未就绪/.test(state.panelTextSample),
|
||||
hasOpenReferenceOrCitationControl: state.counts.openReferenceControls > 0
|
||||
|| state.metaAttrs.referenceBoundary === "mnote-source-registry-weknora-kb-chunk-index"
|
||||
|| /引用|citation|open-reference|打开引用/i.test(state.panelTextSample),
|
||||
};
|
||||
const missing = Object.entries(assertions)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([name]) => name);
|
||||
return { assertions, missing, ok: missing.length === 0 };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await signIn(context);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", ROOT_URI);
|
||||
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const panelNode = document.querySelector('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
if (!(panelNode instanceof HTMLElement)) return false;
|
||||
const text = panelNode.textContent || "";
|
||||
const hasCard = Boolean(panelNode.querySelector('[data-testid="mnote-weknora-kb-card-list"] [data-knowledge-rag-action="select-kb"]'));
|
||||
const statusSettled = !/正在读取知识库 provider 状态/.test(text);
|
||||
return hasCard || statusSettled;
|
||||
}, { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.locator('[data-kb-rag-tab="sources"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.locator('[data-kb-rag-tab="sources"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const state = await collectPageState(page);
|
||||
const verdict = evaluateAssertions(state);
|
||||
const result = {
|
||||
ok: verdict.ok,
|
||||
task: "task79x-weknora-kb-page-browser-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
resultPath: RESULT_PATH,
|
||||
...verdict,
|
||||
state,
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
const failure = {
|
||||
ok: false,
|
||||
task: "task79x-weknora-kb-page-browser-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
error: error.stack || error.message || String(error),
|
||||
};
|
||||
writeJson(FAILURE_PATH, failure);
|
||||
console.error(JSON.stringify(failure, null, 2));
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user