重构基线
This commit is contained in:
@@ -0,0 +1,438 @@
|
|||||||
|
# 5-37 [process] MNote UI Foundation — 统一设计系统与组件基础设施 v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-15
|
||||||
|
>
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> Owner:05-editor-mainline / 03-rust-web / 11-wolai
|
||||||
|
>
|
||||||
|
> 目标:为 MNote 建立统一设计系统与组件基础设施,解决当前 UI 风格分散、组件缺失、交互 primitive 手写重复、CSS 单文件膨胀、SSR/CSR 两套运行时不统一的问题。
|
||||||
|
|
||||||
|
> 本轮完成 goal:`5-37-ui-foundation-p0-icon-qa`。范围是补齐 P0 中可独立落地的 token/portal/toast 基础设施,收紧 Material Symbols 图标验收,并交给 AgentBoard 做风险审查与浏览器 QA。P0 中编辑器全量 Button/Radix focus trap、CSS 全量拆分仍作为后续目标,不在本轮强行扩散。
|
||||||
|
|
||||||
|
## 1. 问题诊断
|
||||||
|
|
||||||
|
### 1.1 当前 UI 资产分布
|
||||||
|
|
||||||
|
| 层次 | 位置 | 规模 | 问题 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| CSS | `styles.rs` | 6718 行单文件 Rust 字符串常量 | 单文件膨胀、无模块拆分、修改风险高 |
|
||||||
|
| SSR 模板 | `layout.rs`, `document.rs`, `home.rs` 等 | ~10 个 Leptos view 组件 | 交互逻辑混入 JS runtime,非 reactive |
|
||||||
|
| 浏览器 runtime | `browser/` | 46 个 JS 文件 | 覆盖全但 ad-hoc,无统一 primitive |
|
||||||
|
| 编辑器 CSR | `editor_runtime/` | 27 个 Rust 文件 | 菜单/overlay 手写,缺 radix 级基础设施 |
|
||||||
|
|
||||||
|
### 1.2 核心缺口
|
||||||
|
|
||||||
|
**组件抽象层缺失**
|
||||||
|
- 没有 `Button/Dialog/Popover/Menu/Tabs/Input/Select/Toast/Skeleton` 等可复用 primitive。
|
||||||
|
- root `package.json` 只有 Playwright + 预览库,无 UI 框架依赖。
|
||||||
|
- recycle 旧前端有 Radix UI + shadcn 的完整组件体系(19 个组件文件),但依赖 React,不能直接用于当前 Rust SSR + CSR 架构。
|
||||||
|
|
||||||
|
**交互 primitive 重复手写**
|
||||||
|
- Escape 关闭:`tree-shell-filetree-menu-runtime.js`、`block_handle_menu_view.rs`、`mindmap_node_view.rs`、`sidebar-page-ai-runtime.js` 各写一份。
|
||||||
|
- outside click 关闭:同上。
|
||||||
|
- focus trap:仅 filetree menu 有基础实现,dialog 没有。
|
||||||
|
- body scroll lock:不存在。
|
||||||
|
- z-index 管理:无统一层叠规范,`1200`、`1000`、`999` 散落在 `styles.rs` 各处。
|
||||||
|
|
||||||
|
**反馈系统缺失**
|
||||||
|
- toast/notification 不存在。错误反馈多数用 `alert()` fallback 或局部 `set_command_feedback`。
|
||||||
|
- 无全局反馈管道。
|
||||||
|
|
||||||
|
**图标系统不统一**
|
||||||
|
- 主壳用 Material Symbols (Google Fonts CDN)。
|
||||||
|
- 编辑器菜单用 Unicode 文本符号(`"✦"` `"↻"` `"⌫"`)。
|
||||||
|
- mindmap 用 emoji + Material Symbols 混合。
|
||||||
|
|
||||||
|
**设计 token 无管线**
|
||||||
|
- CSS 变量已在 `:root` 定义(`--wolai-bg`、`--wolai-text-primary` 等),但没有 token 文件、组件 variant 规范、尺寸等级、状态色。
|
||||||
|
- 6718 行 CSS 直接塞在 Rust 常量里,长期维护不可持续。
|
||||||
|
|
||||||
|
**组件演示与可视化验收缺失**
|
||||||
|
- 无 Storybook 或内部 `/ui-debug` 组件画廊。
|
||||||
|
- 现有 Wolai 视觉 smoke 偏页面级,不是组件级基线。
|
||||||
|
|
||||||
|
**前端工程守门缺失**
|
||||||
|
- root 没有 `lint`、`test`、`format` 脚本。
|
||||||
|
- JS runtime 靠 smoke 和局部 `node --check`,无统一质量门禁。
|
||||||
|
|
||||||
|
**SSR / CSR 双运行时鸿沟**
|
||||||
|
- 主壳是 Leptos SSR → HTML 字符串 + 46 JS runtime。
|
||||||
|
- 编辑器是 Leptos CSR/WASM → reactive signal。
|
||||||
|
- 两个运行时之间没有共享的组件层、token 层或交互契约。
|
||||||
|
|
||||||
|
### 1.3 为什么不能直接搬旧 recycle 前端组件
|
||||||
|
|
||||||
|
- 旧前端依赖 React 19 + Radix UI React + shadcn + Tailwind + Next.js。
|
||||||
|
- 当前主壳是 Rust Leptos SSR + 浏览器 JS runtime,编辑器是 Leptos CSR/WASM。
|
||||||
|
- 不能把 React 组件直接嵌入 Rust 栈,需要适配。
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
1. **单一真相源**:token 只有一份定义,SSR 和 CSR 共同引用。
|
||||||
|
2. **渐进迁移**:不在主壳大规模重写 SSR 架构;先在编辑器 island 落地 primitive,再逐步上溯到主壳。
|
||||||
|
3. **headless 优先**:组件 primitive 不绑定视觉风格,外观由 Wolai 设计 token + CSS 变量控制。
|
||||||
|
4. **事件驱动,取消轮询**:所有 UI 状态同步优先走 realtime event stream / MutationObserver / command result,不新增 `setInterval` 轮询。
|
||||||
|
5. **可测试**:每个 primitive 必须有组件级 smoke,每个 variant/state 有截图或 DOM 断言覆盖。
|
||||||
|
6. **不新增依赖债务**:只用成熟、有维护者的 Leptos 生态库;不自行从零实现已有成熟方案。
|
||||||
|
|
||||||
|
## 3. 技术选型
|
||||||
|
|
||||||
|
### 3.1 编辑器 island (CSR/WASM) 组件 primitive
|
||||||
|
|
||||||
|
**选 radix-leptos v0.9.x**
|
||||||
|
|
||||||
|
| 对比维度 | radix-leptos | thaw-ui | rust-ui/ui | leptonic | 自研 |
|
||||||
|
|---------|-------------|---------|------------|----------|------|
|
||||||
|
| 风格 | headless,不绑定视觉 | Fluent Design | shadcn/Tailwind | Material | 任意 |
|
||||||
|
| 与 Wolai CSS 兼容 | ✅ headless | ❌ Fluent 冲突 | ⚠️ 需 Tailwind | ❌ Material | ✅ |
|
||||||
|
| Leptos 0.8 兼容 | ✅ v0.9.0 | ✅ v0.5-beta | ✅ | ✅ | ✅ |
|
||||||
|
| 组件数量 | 57+ | ~40 | ~30 | ~20 | 0 |
|
||||||
|
| 测试覆盖 | 1792+ tests | 未知 | 未知 | 未知 | 0 |
|
||||||
|
| 维护状态 | 活跃 (Q2 2026) | 活跃 | 活跃 | 稳定 | N/A |
|
||||||
|
| WASM 体积 | 538KB 优化后 | 未知 | 未知 | 未知 | 0 |
|
||||||
|
| FocusTrap/Escape/Portal | ✅ 全套 | 未知 | 无 | 无 | 需自建 |
|
||||||
|
|
||||||
|
结论:radix-leptos 是唯一提供完整 headless primitive + a11y infrastructure 的 Leptos 生态库,与 MNote Wolai CSS 变量体系零冲突。
|
||||||
|
|
||||||
|
### 3.2 主壳 (SSR + JS runtime) 过渡策略
|
||||||
|
|
||||||
|
短期不迁移 SSR 架构,但做三件事:
|
||||||
|
|
||||||
|
1. **CSS 拆分**:`styles.rs` → `styles/tokens.css` + `styles/components/` + `styles/pages/`。
|
||||||
|
2. **交互契约统一**:参考 radix-leptos 的 hook 行为语义,统一 JS runtime 中的 escape/focus trap/outside click 模式。
|
||||||
|
3. **Portal 容器标准化**:`layout.rs` 中预留统一 portal root (`#mnote-portal-root`),所有 popover/dialog/menu 渲染到此处。
|
||||||
|
|
||||||
|
### 3.3 图标系统
|
||||||
|
|
||||||
|
统一使用 Material Symbols。
|
||||||
|
- 编辑器菜单 Unicode 文本符号逐步替换为 Material Symbols。
|
||||||
|
- 维护 `ICON_MAP` 常量将语义名映射到 ligature。
|
||||||
|
- 不引入额外图标库。
|
||||||
|
|
||||||
|
### 3.4 构建工具
|
||||||
|
|
||||||
|
- **编辑器 island**:现有 `trunk` 构建不变,`Cargo.toml` 加 `radix-leptos-primitives` 依赖。
|
||||||
|
- **主壳 CSS**:保持 Rust `include_str!` 编译期嵌入,但源文件拆分为多个 `.css` 文件。
|
||||||
|
- **token 源**:一个 `tokens.css` 文件,被主壳和编辑器共同引用。
|
||||||
|
|
||||||
|
## 4. 设计 token 体系
|
||||||
|
|
||||||
|
### 4.1 设计变量分层
|
||||||
|
|
||||||
|
```
|
||||||
|
Layer 0: Raw Colors (--color-basic-50 .. --color-basic-900)
|
||||||
|
Layer 1: Semantic Colors (--wolai-bg, --wolai-text-primary, --wolai-border, --wolai-brand ...)
|
||||||
|
Layer 2: Component Tokens (--mnote-btn-bg, --mnote-dialog-shadow, --mnote-popover-radius ...)
|
||||||
|
Layer 3: State Tokens (--mnote-state-hover, --mnote-state-active, --mnote-state-disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
Layer 0-1 已存在于 `styles.rs` 的 `:root` 块。Layer 2-3 当前缺失。
|
||||||
|
|
||||||
|
### 4.2 尺寸与间距等级
|
||||||
|
|
||||||
|
```css
|
||||||
|
--wolai-space-xs: 4px;
|
||||||
|
--wolai-space-sm: 8px;
|
||||||
|
--wolai-space-md: 12px;
|
||||||
|
--wolai-space-lg: 16px;
|
||||||
|
--wolai-space-xl: 24px;
|
||||||
|
--wolai-space-2xl: 32px;
|
||||||
|
|
||||||
|
--wolai-radius-sm: 4px;
|
||||||
|
--wolai-radius-md: 6px;
|
||||||
|
--wolai-radius-lg: 8px;
|
||||||
|
--wolai-radius-xl: 12px;
|
||||||
|
|
||||||
|
--wolai-font-xs: 12px;
|
||||||
|
--wolai-font-sm: 13px;
|
||||||
|
--wolai-font-md: 14px;
|
||||||
|
--wolai-font-lg: 16px;
|
||||||
|
--wolai-font-xl: 20px;
|
||||||
|
--wolai-font-2xl: 24px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 阴影等级
|
||||||
|
|
||||||
|
```css
|
||||||
|
--wolai-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
--wolai-shadow-md: 0 4px 12px rgba(15, 23, 42, 0.12);
|
||||||
|
--wolai-shadow-lg: 0 18px 54px rgba(15, 23, 42, 0.18);
|
||||||
|
--wolai-shadow-overlay: 0 18px 48px rgba(15, 23, 42, 0.22), 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 z-index 层级
|
||||||
|
|
||||||
|
```css
|
||||||
|
--wolai-z-sidebar: 100;
|
||||||
|
--wolai-z-topbar: 200;
|
||||||
|
--wolai-z-floating: 500;
|
||||||
|
--wolai-z-popover: 1000;
|
||||||
|
--wolai-z-dialog-backdrop: 1100;
|
||||||
|
--wolai-z-dialog: 1200;
|
||||||
|
--wolai-z-toast: 1300;
|
||||||
|
--wolai-z-tooltip: 1400;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 组件清单与优先级
|
||||||
|
|
||||||
|
### 5.1 P0 — 编辑器 island 落地(blocking 风格统一)
|
||||||
|
|
||||||
|
| 组件 | radix-leptos 对应 | 当前手写位置 | 迁移收益 |
|
||||||
|
|------|------------------|-------------|---------|
|
||||||
|
| Button | `Button` (6 variants) | 各处零散 `<button>` | 统一 variant/size/disabled/loading |
|
||||||
|
| Dialog/Modal | `Dialog` + `AlertDialog` | `mnote-profile-dialog` CSS only | escape/focus trap/backdrop click |
|
||||||
|
| Popover | `Popover` | 页面设置 popover JS 手写 | 定位/关闭/层级统一 |
|
||||||
|
| DropdownMenu | `DropdownMenu` | block handle menu (Rust) | 键盘/子菜单/分隔线 |
|
||||||
|
| ContextMenu | `ContextMenu` | filetree context menu (JS) | 右键定位/键盘/a11y |
|
||||||
|
| Tabs | `Tabs` | sidebar tabs, page settings | 键盘切换/aria/动画 |
|
||||||
|
| Toast | 自建薄封装 | 无,`alert()` fallback | 全局反馈 |
|
||||||
|
|
||||||
|
### 5.2 P1 — 编辑器增强
|
||||||
|
|
||||||
|
| 组件 | radix-leptos 对应 | 用途 |
|
||||||
|
|------|------------------|------|
|
||||||
|
| Select | `Select` | 页面下拉选择 |
|
||||||
|
| Checkbox | `Checkbox` | 页面设置选项 |
|
||||||
|
| Toggle/Switch | `Switch` | toggle 控件 |
|
||||||
|
| CommandPalette | `CommandPalette` | slash menu 容器 |
|
||||||
|
| Skeleton | 自建 | 加载占位 |
|
||||||
|
| ScrollArea | `ScrollArea` | 统一滚动容器 |
|
||||||
|
|
||||||
|
### 5.3 P2 — 主壳 SSR 统一
|
||||||
|
|
||||||
|
- 主壳 popover 从 JS 手写迁到 radix-leptos hydration。
|
||||||
|
- 主页 `PageLayout` 中按钮、搜索 modal、account menu 统一 variant。
|
||||||
|
- 垃圾桶 modal 统一 Dialog 组件。
|
||||||
|
|
||||||
|
## 6. 主壳 CSS 重构方案
|
||||||
|
|
||||||
|
### 6.1 拆分目标
|
||||||
|
|
||||||
|
```
|
||||||
|
rust/crates/mnote-web/src/ssr/
|
||||||
|
├── styles/
|
||||||
|
│ ├── mod.rs # 组装入口,重新导出 MNOTE_CSS
|
||||||
|
│ ├── tokens.css # Layer 0-3 设计变量
|
||||||
|
│ ├── reset.css # 基础重置
|
||||||
|
│ ├── layout.css # 主壳布局
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── button.css
|
||||||
|
│ │ ├── dialog.css
|
||||||
|
│ │ ├── popover.css
|
||||||
|
│ │ ├── menu.css
|
||||||
|
│ │ ├── tabs.css
|
||||||
|
│ │ ├── input.css
|
||||||
|
│ │ ├── select.css
|
||||||
|
│ │ ├── toggle.css
|
||||||
|
│ │ ├── tooltip.css
|
||||||
|
│ │ ├── toast.css
|
||||||
|
│ │ ├── skeleton.css
|
||||||
|
│ │ └── badge.css
|
||||||
|
│ └── pages/
|
||||||
|
│ ├── sidebar.css
|
||||||
|
│ ├── document.css
|
||||||
|
│ ├── search.css
|
||||||
|
│ ├── auth.css
|
||||||
|
│ ├── admin.css
|
||||||
|
│ ├── trash.css
|
||||||
|
│ ├── knowledge_rag.css
|
||||||
|
│ └── page_ai.css
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 组装方式
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// styles/mod.rs
|
||||||
|
pub const MNOTE_CSS: &str = concat!(
|
||||||
|
include_str!("tokens.css"),
|
||||||
|
include_str!("reset.css"),
|
||||||
|
include_str!("layout.css"),
|
||||||
|
include_str!("components/button.css"),
|
||||||
|
include_str!("components/dialog.css"),
|
||||||
|
// ...
|
||||||
|
include_str!("pages/sidebar.css"),
|
||||||
|
// ...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
保持编译期嵌入,零运行时开销,源文件可独立维护。
|
||||||
|
|
||||||
|
### 6.3 CSS 命名规范
|
||||||
|
|
||||||
|
- 组件:`.mnote-btn`、`.mnote-dialog`。
|
||||||
|
- 变体:`data-variant="primary"` / `"ghost"`。
|
||||||
|
- 尺寸:`data-size="sm"` / `"lg"`。
|
||||||
|
- 状态:`data-state="open"` / `"closed"`、`aria-expanded`。
|
||||||
|
- 页面:`.mnote-search`、`.mnote-auth`。
|
||||||
|
- 旧 `wolai-*` 类逐步迁移到 `mnote-*`,保留 `wolai-*` 别名过渡一个版本。
|
||||||
|
|
||||||
|
## 7. 编辑器 island 集成 radix-leptos 的切口
|
||||||
|
|
||||||
|
### 7.1 依赖
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# rust/spikes/leptos-tiptap-spike/Cargo.toml
|
||||||
|
[dependencies]
|
||||||
|
radix-leptos-primitives = { version = "0.9.0", features = ["full"] }
|
||||||
|
radix-leptos-core = "0.9.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 首个替换:block handle menu
|
||||||
|
|
||||||
|
`block_handle_menu_view.rs` 320 行手写浮动菜单,用 `DropdownMenu` 替换可删约 200 行事件处理代码,同时获键盘导航、ARIA、focus 管理。
|
||||||
|
|
||||||
|
### 7.3 第二个替换:slash menu
|
||||||
|
|
||||||
|
`slash_menu_view.rs` 300 行手写弹出菜单,用 `CommandPalette` 替换,保留过滤逻辑,获标准键盘导航、ARIA。
|
||||||
|
|
||||||
|
### 7.4 第三个替换:table toolbar options
|
||||||
|
|
||||||
|
`table_toolbar_view.rs` 表格选项菜单,用 `DropdownMenu` 替换。
|
||||||
|
|
||||||
|
## 8. 交互契约统一
|
||||||
|
|
||||||
|
### 8.1 全局 Portal Root
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="mnote-portal-root" style="position:fixed;inset:0;pointer-events:none;z-index:var(--wolai-z-popover)">
|
||||||
|
<!-- popover/dialog/menu/toast 渲染到此 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 交互模式标准化
|
||||||
|
|
||||||
|
| 行为 | 当前实现 | 统一方式 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Escape 关闭 | 各处手写 `event.key === "Escape"` | `use_escape_keydown` |
|
||||||
|
| Outside click 关闭 | 各处手写 mousedown listener | `use_outside_click` |
|
||||||
|
| Focus trap | filetree menu JS 手写 | `use_focus_trap` |
|
||||||
|
| Body scroll lock | 无 | `use_body_scroll_lock` |
|
||||||
|
| 层级管理 | 各处手写 z-index | CSS 变量 `--wolai-z-*` + portal root |
|
||||||
|
|
||||||
|
### 8.3 JS Runtime 对齐
|
||||||
|
|
||||||
|
JS runtime 的 popover/menu/dialog 创建行为参考 radix-leptos 的 DOM 结构和 ARIA 属性,保证:相同的 `role`/`aria-*`、相同的 `data-state`、相同的 z-index 变量。
|
||||||
|
|
||||||
|
## 9. Toast 通知系统
|
||||||
|
|
||||||
|
- 位置:右下角或顶部居中。
|
||||||
|
- 类型:success / error / warning / info / loading。
|
||||||
|
- 行为:自动消失(可配置 duration)、可手动关闭、可堆叠。
|
||||||
|
- 主壳:JS runtime 薄封装,`mnote.toast({ type, title, duration })`。
|
||||||
|
- 编辑器:`<ToastProvider>` + `use_toast()` hook,约 150 行。
|
||||||
|
|
||||||
|
## 10. 组件演示页
|
||||||
|
|
||||||
|
`GET /ui-debug/components`(仅 debug routes 启用时)
|
||||||
|
|
||||||
|
展示:所有 P0 组件 variant × size × state 矩阵,可切换亮/暗,可交互,自动截图用于视觉回归。
|
||||||
|
|
||||||
|
## 11. 前端工程守门
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"lint:js": "node --check rust/crates/mnote-web/browser/*.js",
|
||||||
|
"lint:css": "npx stylelint 'rust/crates/mnote-web/src/ssr/styles/**/*.css'",
|
||||||
|
"lint:rs": "cd rust && cargo clippy --workspace -- -D warnings",
|
||||||
|
"format:rs": "cd rust && cargo fmt -- --check",
|
||||||
|
"test:rs": "cd rust && cargo test --workspace",
|
||||||
|
"test:smoke": "node scripts/task490-runtime-surfaces-smoke.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS 体积护栏:总 CSS 不超过当前 6718 行的 120%。
|
||||||
|
|
||||||
|
## 12. Checklist
|
||||||
|
|
||||||
|
### Phase A:设计 token 与 CSS 重构(P0)
|
||||||
|
|
||||||
|
- [x] A1. 从 `styles.rs` 提取 Layer 0-1 变量到 `tokens.css`
|
||||||
|
- [x] A2. 补充 Layer 2-3 变量(组件 token、状态 token、间距、阴影、z-index)— 已补 font-size、button component tokens、radius、shadow overlay、toast z-index
|
||||||
|
- [ ] A3. 按组件/页面拆分 `styles.rs` 为 `styles/` 目录 — ⚠️ 目录已建但未按组件拆分:reset/layout 合为 base.css,components/ 只有单文件 main.css(5693行),pages/ 缺 document/search/admin/trash/knowledge_rag/page_ai
|
||||||
|
- [ ] A4. 建立 CSS 命名规范:组件用 `data-variant`/`data-size`/`data-state` — ⚠️ 已加 `data-state` 属性但无 `data-variant`/`data-size` 体系
|
||||||
|
- [x] A5. 保留 `mnote_css_is_reasonably_sized` 护栏通过(836/838 tests pass)
|
||||||
|
- [ ] A6. 所有现有 smoke 通过 — ⚠️ 2 个预存测试失败(非 5-37 引入),smoke 待运行
|
||||||
|
|
||||||
|
### Phase B:编辑器 island radix-leptos 集成(P0)
|
||||||
|
|
||||||
|
- [x] B1. `leptos-tiptap-spike` 加 `radix-leptos-primitives` 依赖,编译通过
|
||||||
|
- [x] B2. 用 `DropdownMenu` 替换 `block_handle_menu_view` 手写菜单
|
||||||
|
- [x] B3. 用 `CommandPalette` 替换 `slash_menu_view` 键盘导航
|
||||||
|
- [x] B4. 用 `DropdownMenu` 替换 `table_toolbar_view` options 菜单
|
||||||
|
- [ ] B5. 编辑器内 `<button>` 使用 radix `Button` 组件 — ⚠️ `EditorButton` 基础组件可编译,但运行面仍未全量替换
|
||||||
|
- [ ] B6. 编辑器内 Escape/outside click/focus trap 替换为 radix hooks — ⚠️ `dismiss.rs` / focus trap 可编译,尚未接入主要 overlay
|
||||||
|
- [ ] B7. WASM bundle 增量 < 200KB (gzipped) — ⚠️ `cargo build --lib --target wasm32-unknown-unknown --release` 通过;重新生成 runtime artifact 会触发 Leptos SSR feature unification panic,bundle 增量未作为完成证据
|
||||||
|
- [ ] B8. `task490-runtime-surfaces-smoke.js` 通过 — ⚠️ smoke 已覆盖 Page AI stop 与图标容器,但当前 generated island 仍有旧 block menu glyph(`↻`),未通过
|
||||||
|
|
||||||
|
### Phase C:全局 Portal & Toast 基础设施(P0)
|
||||||
|
|
||||||
|
- [x] C1. `layout.rs` 新增 `#mnote-portal-root`
|
||||||
|
- [x] C2. JS runtime 新增 `mnote.toast()` API(`mnote-ui-runtime.js`)
|
||||||
|
- [ ] C3. 替换 `fallback: 'alert'` 为 toast 调用 — ❌ 未替换
|
||||||
|
- [ ] C4. 编辑器新增 `<ToastProvider>` + `use_toast()` — ❌ 未实现
|
||||||
|
- [x] C5. Toast 样式符合 Wolai 风格(`components/toast.css`)
|
||||||
|
- [x] C6. 补 toast smoke(`task500-ui-debug-components-smoke.js` 覆盖 UI debug toast,可输出截图)
|
||||||
|
|
||||||
|
### Phase D:图标系统统一(P0)
|
||||||
|
|
||||||
|
- [x] D1. 建立 `ICON_MAP` 常量(`icons.rs` 有 48 个映射)
|
||||||
|
- [ ] D2. 编辑器 Unicode 文本符号替换为 Material Symbols(via `material_icon()`)— ⚠️ source 已替换,当前 checked-in generated island 仍可见旧 block/slash glyph,需补 artifact 生成链路
|
||||||
|
- [x] D3. mindmap emoji 图标统一(`mindmap_node_view.rs` 已修改)
|
||||||
|
- [x] D4. 所有 icon 使用同一 `<span class="material-symbols-outlined">` 约定
|
||||||
|
|
||||||
|
### Phase E:组件演示页与可视化验收(P1)
|
||||||
|
|
||||||
|
- [x] E1. 新增 `/ui-debug/components` 路由(需要 `MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES=1`)
|
||||||
|
- [ ] E2. 展示 P0 组件 variant × size × state 矩阵 — ⚠️ 路由已建但 debug routes 默认关闭,未验证
|
||||||
|
- [x] E3. 补组件级 Playwright smoke(`task500-ui-debug-components-smoke.js`)
|
||||||
|
- [x] E4. 截图输出到 `tmp/ui-components/` — `MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES=1 node scripts/task500-ui-debug-components-smoke.js`
|
||||||
|
|
||||||
|
### Phase F:主壳 SSR 组件统一(P2)
|
||||||
|
|
||||||
|
- [ ] F1. 搜索 modal 统一 Dialog 语义 — ⚠️ 搜索 overlay 有样式但未完整 Dialog 语义
|
||||||
|
- [ ] F2. 页面设置 popover 统一样式变量 — ⚠️ popover 样式部分统一
|
||||||
|
- [ ] F3. account menu / workspace source menu 统一 DropdownMenu 模式 — ❌ 仅加了 `data-state` 属性,未替换为 DropdownMenu
|
||||||
|
- [ ] F4. 垃圾桶 modal 统一 Dialog 语义 — ❌ 未处理
|
||||||
|
- [ ] F5. 资料库设置 popover 统一样式变量 — ⚠️ KB rag settings popover 样式存在(82 处引用)
|
||||||
|
|
||||||
|
## 13. 验收证据模板
|
||||||
|
|
||||||
|
### 13.1 当前本地证据(2026-06-19)
|
||||||
|
|
||||||
|
- `cargo test -p mnote-web mnote_css_does_not_hide_all_closed_state_triggers` 通过,证明不再存在全局 `[data-state="closed"]` 隐藏 trigger 的回归。
|
||||||
|
- `cargo test -p mnote-web mnote_css_contains_ui_foundation_tokens_and_toast` 通过,证明 token/toast 样式已进入 `MNOTE_CSS`。
|
||||||
|
- `cargo test -p mnote-web page_layout_hides_public_state_and_exposes_sidebar_shortcut_star` 与 `mnote_ui_runtime_exposes_toast_api` 通过,证明 portal/toast runtime 已挂入主壳。
|
||||||
|
- `cargo build --lib --target wasm32-unknown-unknown --release` 在 `rust/spikes/leptos-tiptap-spike` 通过,证明当前 editor island source 可编译。
|
||||||
|
- 本地 Chrome 截图验证三处用户报告回归:`tmp/5-37-ui-regression-manual/01-initial-document.png`、`02-after-knowledge-click.png`、`03-after-workspace-click.png`、`04-after-page-ai-click.png`;Page AI、知识库按钮、左上角空间切换均可见并可交互。
|
||||||
|
- `task490-runtime-surfaces-smoke.js` 仍未完成通过:当前 checked-in generated island 中 block handle menu 仍泄漏旧 `↻` glyph;直接重新生成 artifact 会因 `tachys`/`leptos` 的 `ssr` feature unification 在浏览器端 panic,需先修正生成链路再把 D2/B8 标为完成。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CSS 护栏
|
||||||
|
cargo test -p mnote-web mnote_css_is_reasonably_sized
|
||||||
|
|
||||||
|
# 编辑器 WASM 编译
|
||||||
|
cd rust && cargo build -p mnote-leptos-tiptap-spike --target wasm32-unknown-unknown
|
||||||
|
|
||||||
|
# 关键 smoke
|
||||||
|
node scripts/task490-runtime-surfaces-smoke.js
|
||||||
|
node scripts/task114-rust-web-gateway-entry-smoke.js
|
||||||
|
|
||||||
|
# JS syntax check
|
||||||
|
node --check rust/crates/mnote-web/browser/*.js
|
||||||
|
|
||||||
|
# Rust lint
|
||||||
|
cd rust && cargo fmt --check -p mnote-web -p mnote-leptos-tiptap-spike
|
||||||
|
cd rust && cargo clippy -p mnote-web -p mnote-leptos-tiptap-spike -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. 不做什么
|
||||||
|
|
||||||
|
- 不把 SSR 主壳全量迁到 Leptos hydration(Phase F 只统一样式,不迁移渲染架构)
|
||||||
|
- 不引入 Tailwind CSS
|
||||||
|
- 不引入 React/Next.js 组件库
|
||||||
|
- 不新建 npm 前端项目
|
||||||
|
- 不在本设计做暗色模式完整方案(Good Night 已有基础,后续单独设计)
|
||||||
|
- 不清理 recycle/wolai-frontend(保持对照参考)
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
> 状态补充(2026-06-25):本稿降级为 opencode runtime / iframe fallback 参考;Page AI 产品主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。
|
||||||
|
|
||||||
|
# 7-65 [process] Page AI opencode WebUI embed v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-23
|
||||||
|
>
|
||||||
|
> 当前状态:`PROCESS / opencode-chat style iframe MVP 已落地,bridge 与 event receipt 已补第一版`
|
||||||
|
>
|
||||||
|
> Owner:07-ai / Page AI / opencode WebUI embed
|
||||||
|
>
|
||||||
|
> 替代方案:
|
||||||
|
> - `design/old/07-ai/process/7-62-recycle-page-ai-board-first-full-rewrite-v1.md`
|
||||||
|
> - `design/old/07-ai/process/7-63-recycle-page-ai-board-first-productization-v1.md`
|
||||||
|
> - `design/old/07-ai/process/7-64-recycle-codexmobile-embed-page-ai-v1.md`
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
|
||||||
|
> - `design/07-ai/done/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
|
||||||
|
> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md`
|
||||||
|
> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
|
||||||
|
|
||||||
|
## 1. 核心结论
|
||||||
|
|
||||||
|
废弃 MNote Page AI 自研 provider 接入与 Board-first 产品壳,改为 **只接入 opencode 官方 runtime + 官方 WebUI**。
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI = MNote 宿主壳 + opencode 官方 WebUI + MNote 打开/刷新/上下文集成
|
||||||
|
opencode = 官方 WebUI + 官方 HTTP server/OpenAPI/SSE + 官方 SDK + opencodego provider
|
||||||
|
```
|
||||||
|
|
||||||
|
MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker 作为页面 AI provider。它们可以继续存在于历史、调试或外部工作流边界,但不进入 Page AI 主路径。
|
||||||
|
|
||||||
|
选择 opencode 的原因:
|
||||||
|
- `opencode web` 官方提供本地 WebUI,不需要 MNote 自研完整聊天前端。
|
||||||
|
- `opencode serve` 官方提供 headless HTTP server 和 OpenAPI,适合 MNote 做轻量 adapter。
|
||||||
|
- `@opencode-ai/sdk` 覆盖 session、message、diff、permission、event,足够承接上下文注入与回写 receipt。
|
||||||
|
- opencode 原生支持 SSE、权限审批、文件 diff、MCP、ACP、session export/import。
|
||||||
|
- 本机已有 `opencode` 和 opencodego 订阅链路,provider/runtime/UI 属于同一生态,少一层兼容债。
|
||||||
|
|
||||||
|
## 2. 明确废弃
|
||||||
|
|
||||||
|
### 2.1 Page AI 主路径不再接入
|
||||||
|
|
||||||
|
- Reasonix native session / Reasonix desktop bridge。
|
||||||
|
- ZCode worker / Board worker selector。
|
||||||
|
- Hermes profile / Hermes Web control surface。
|
||||||
|
- Chat-only remote conversation。
|
||||||
|
- Agent Board run/workflow 作为默认 Page AI 后端。
|
||||||
|
- CodexMobile iframe 作为默认 Page AI 后端。
|
||||||
|
|
||||||
|
这些能力不删除历史代码,不立刻清理工具层,只从 Page AI 新主路径退出。
|
||||||
|
|
||||||
|
### 2.2 仍可保留的边界
|
||||||
|
|
||||||
|
- `mnote.doc.*`、`mnote.block.*`、LightRAG facade 等工具可继续作为 opencode 可调用的 MCP/工具能力。
|
||||||
|
- Agent Board 仍可作为外部 workflow/QA/review 系统,不再作为 Page AI 默认聊天后端。
|
||||||
|
- CodexMobile 可保留为备选 spike 或体验对照,不作为当前实现目标。
|
||||||
|
|
||||||
|
## 3. 新系统边界
|
||||||
|
|
||||||
|
### 3.1 MNote 只做四件事
|
||||||
|
|
||||||
|
1. **上下文**:当前页、选区、页面标题、真实 `.md` 路径、workspaceId、allowed roots、LightRAG 引用。
|
||||||
|
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。
|
||||||
|
|
||||||
|
### 3.2 opencode 负责完整 agent runtime
|
||||||
|
|
||||||
|
- 聊天 UI。
|
||||||
|
- 流式事件。
|
||||||
|
- session 管理与恢复。
|
||||||
|
- provider/model 调用。
|
||||||
|
- tool call 展示。
|
||||||
|
- permission ask/allow/deny。
|
||||||
|
- 文件读写、patch、diff。
|
||||||
|
- MCP / ACP / agent 配置。
|
||||||
|
|
||||||
|
### 3.3 集成形态:不是新的 Leptos island
|
||||||
|
|
||||||
|
当前 MNote 文档编辑器已经是 `leptos_tiptap_island`,但 Page AI 不应该再做一个重前端 island。Page AI 更像 VSCode 里的 Cline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VSCode workbench host + Cline webview/extension
|
||||||
|
MNote web shell host + opencode WebUI iframe/bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
因此第一版形态是 **mnote-web sidebar host runtime**:
|
||||||
|
- 继续使用 `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` 作为宿主入口,但把它瘦身成 opencode host。
|
||||||
|
- iframe 内尽量保留 opencode 官方 UI,包括消息、tool、permission、diff/session 页面。
|
||||||
|
- iframe 外只放 MNote 必要宿主控件:当前页 context bar、打开变更、刷新当前页、授权状态、运行状态。
|
||||||
|
- 不新建 Leptos island,不引入 React/Vue 到 MNote 主壳,不重写 opencode 消息 UI。
|
||||||
|
|
||||||
|
如果后续必须深度定制 opencode UI,也优先 fork opencode WebUI 的少量页面,而不是在 MNote 里复刻一套聊天前端。
|
||||||
|
|
||||||
|
## 4. 集成拓扑
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Rust SSR :3000
|
||||||
|
└─ Page AI sidebar
|
||||||
|
├─ MNote host chrome
|
||||||
|
│ [当前页] [选区] [可写目录] [知识库引用]
|
||||||
|
│ [打开变更] [刷新当前页] [在主编辑区打开]
|
||||||
|
└─ iframe http://127.0.0.1:4096/<project>/session
|
||||||
|
↓ first MVP direct localhost iframe
|
||||||
|
opencode web :4096
|
||||||
|
├─ 官方 WebUI
|
||||||
|
├─ 官方 HTTP server / OpenAPI
|
||||||
|
├─ SSE /event
|
||||||
|
├─ session/message/diff/permission APIs
|
||||||
|
└─ opencodego / configured providers
|
||||||
|
```
|
||||||
|
|
||||||
|
第一版优先 iframe 官方 WebUI,不 fork、不精简、不重写样式。实测 opencode WebUI 使用根路径 `/assets`、`/session`、`/global/health` 等资源/API,子路径 `/page-ai/opencode/` iframe 会产生 root path 错位;因此 MVP 采用 MNote 同源根路径反代 `/<base64-project>/session/<sessionId>`,让 iframe 内 `location.pathname` 与 opencode 官方 WebUI 预期保持一致,同时通过 MNote 登录态保护反代入口。`/api/page-ai/opencode/*` 负责 session binding、context 注入、status、diff/receipt adapter。只有 iframe/adapter 实测无法满足产品嵌入时,才考虑 fork WebUI。
|
||||||
|
|
||||||
|
### 4.1 MNote host chrome
|
||||||
|
|
||||||
|
Page AI 抽屉由 MNote 控制尺寸、开关、上下文和跨应用动作,opencode 只负责 AI 交互主体。
|
||||||
|
|
||||||
|
宿主控件最小集:
|
||||||
|
- 当前页 pill:标题、相对路径、读写状态。
|
||||||
|
- 选区 pill:有选区才展示,点击可重新注入上下文。
|
||||||
|
- 变更 pill:来自 opencode diff/event,点击用 MNote 打开对应文件。
|
||||||
|
- 刷新按钮:调用 `window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument()`。
|
||||||
|
- 打开按钮:调用 `window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。
|
||||||
|
|
||||||
|
这些控件由 MNote 渲染,避免修改 opencode 官方页面结构。
|
||||||
|
|
||||||
|
### 4.2 MNote 打开 opencode 变更
|
||||||
|
|
||||||
|
opencode WebUI 里的 diff / changed file 默认按 opencode 自己的 UI 打开。MNote 需要额外提供宿主打开能力:
|
||||||
|
|
||||||
|
```text
|
||||||
|
opencode diff/event
|
||||||
|
→ MNote adapter 归一化 changed files
|
||||||
|
→ Page AI host chrome 显示 changed file chips
|
||||||
|
→ 用户点击 chip
|
||||||
|
→ window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path })
|
||||||
|
```
|
||||||
|
|
||||||
|
第一版不强行改 opencode diff 内部点击行为;先在 iframe 外给 MNote-native changed file chips。这样即使 opencode DOM 改版,MNote 打开变更仍可用。
|
||||||
|
|
||||||
|
### 4.3 可选 bridge:只做宿主动作,不接管 UI
|
||||||
|
|
||||||
|
如果需要让 opencode 官方 UI 内部的文件链接也能用 MNote 打开,可在同源反代 HTML 中注入一个很小的 bridge 脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
opencode iframe click(file/diff link)
|
||||||
|
→ postMessage({ type: 'mnote:open-file', path })
|
||||||
|
→ parent MNote host 调用 openResourceInActiveTab
|
||||||
|
```
|
||||||
|
|
||||||
|
bridge 只允许三类消息:
|
||||||
|
- `mnote:open-file`
|
||||||
|
- `mnote:refresh-file`
|
||||||
|
- `mnote:session-ready`
|
||||||
|
|
||||||
|
不通过 bridge 解析模型事件、不重绘消息、不替换 permission UI。DOM 选择器脆弱时立即退回 host chrome chips。
|
||||||
|
|
||||||
|
## 5. 最小实现
|
||||||
|
|
||||||
|
### Phase A:runtime spike
|
||||||
|
|
||||||
|
- [x] 运行 `opencode --version`、`opencode web --help`、`opencode serve --help`;当前版本已升级到 `1.17.9`。
|
||||||
|
- [x] 卸载 oh-my-openagent / oh-my-opencode 默认插件:`opencode.json` 中 `plugin` 已为空,`oh-my-openagent.jsonc` 已移除并备份。
|
||||||
|
- [x] 启动/复用 `opencode serve --hostname=127.0.0.1 --port=4096`,`/global/health` 返回 healthy。
|
||||||
|
- [x] 验证 WebUI 可打开、可进入 `/mnt/Data1T/mnote` project session、可真实回复。
|
||||||
|
- [x] 验证 opencodego/OmniRoute 模型可用:iframe 内真实回复 `OPENCODE_MNOTE_IFRAME_OK_*`。
|
||||||
|
- [x] 验证当前工作目录指向 MNote workspace:`/session` 返回 `directory=/mnt/Data1T/mnote`。
|
||||||
|
- [x] 验证编辑一个 `.md` 文件后,`/session/:id/diff` 能返回文件 diff:`opencode-smoke-test.md` 返回 `modified` diff。
|
||||||
|
- [ ] `/event` SSE 只做了接口可达性探索,尚未接入持续事件消费。
|
||||||
|
|
||||||
|
### Phase B:MNote iframe embed
|
||||||
|
|
||||||
|
- [x] Rust 新增 `/page-ai/opencode/{*path}` 反向代理到 `127.0.0.1:4096`,并新增 `/api/page-ai/opencode/status`、`/api/page-ai/opencode/diff`。
|
||||||
|
- [x] 只允许已登录 MNote session 访问反代/API;未登录请求返回 `401 page_ai_opencode_unauthorized`。
|
||||||
|
- [x] Page AI sidebar 精简为:MNote host chrome + iframe + basic status。
|
||||||
|
- [x] 尽量不改 opencode WebUI,保留官方页面布局、消息样式、tool/diff/permission UI。
|
||||||
|
- [x] iframe 容器与 host chrome 由 `sidebar-page-ai-runtime.js` + `page-ai.css` 承载,不新增 Page AI Leptos island。
|
||||||
|
- [x] WebUI 默认 iframe 使用 MNote 同源 `/<base64-project>/session` 反代;避免局域网浏览器访问自身 `127.0.0.1:4096`,同时保留 opencode 官方 URL 形态。
|
||||||
|
|
||||||
|
### Phase B2:MNote-native changed files
|
||||||
|
|
||||||
|
- [x] 建立 opencode sessionId ↔ MNote host 状态的持久 binding:`/api/page-ai/opencode/session` 创建/复用 session,并落 SQLite/control-plane,按用户/session/workspace 区分。
|
||||||
|
- [x] 通过 `/api/page-ai/opencode/events` 代理 opencode `/event`,收到 session/message/diff/file 事件后触发有界 refresh。
|
||||||
|
- [x] 通过 `/session/:id/diff` 生成 changed file chips。
|
||||||
|
- [x] chip 点击走 `window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。
|
||||||
|
- [x] 当前页刷新按钮已走 `refreshPrimaryDocument({ reason: 'page-ai-opencode-host' })`;changed files 命中当前页时由 event/diff refresh 链路自动触发刷新。
|
||||||
|
|
||||||
|
### Phase C:context 注入
|
||||||
|
|
||||||
|
优先不修改 opencode WebUI,通过官方 API/SDK 注入上下文:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote open Page AI
|
||||||
|
→ create or resume opencode session
|
||||||
|
→ session.prompt(noReply=true, parts=[MNote context envelope])
|
||||||
|
→ iframe 打开对应 session
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文 envelope 内容:
|
||||||
|
- 当前页标题。
|
||||||
|
- 当前页真实 Markdown 路径。
|
||||||
|
- selection 文本。
|
||||||
|
- allowed roots 与读写权限。
|
||||||
|
- LightRAG 引用摘要。
|
||||||
|
- 当前任务约束:优先编辑 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 边界。
|
||||||
|
|
||||||
|
### Phase D:writeback / receipt
|
||||||
|
|
||||||
|
- [x] 监听 opencode `/event` 或 SDK `event.subscribe()`:当前通过 `/api/page-ai/opencode/events` 代理 `/event`,EventSource 收到相关事件后触发有界 refresh。
|
||||||
|
- [partial] prompt / event 后通过绑定 session 的 `/session/:id/diff` 刷新 changed files;仍需继续核对 opencode 各类 file/diff event payload。
|
||||||
|
- [x] changed files 命中当前页时调用 `refreshPrimaryDocument({ reason: 'page-ai-opencode-event' })`;手动刷新按钮保留。
|
||||||
|
- [x] 保存 MNote pageId ↔ opencode sessionId binding:当前已落 SQLite/control-plane,按用户/session/workspace 区分;浏览器刷新后可恢复同一 binding。
|
||||||
|
- [x] Page AI context bar 显示最近一次 changed files / runtime / error 摘要。
|
||||||
|
|
||||||
|
### Phase E:可选 WebUI bridge
|
||||||
|
|
||||||
|
- [ ] 只有 host chrome chips 体验不足时,才在反代层注入 `mnote-opencode-bridge.js`。
|
||||||
|
- [ ] bridge 只把 opencode UI 内部文件点击转成 `postMessage`。
|
||||||
|
- [ ] bridge 不解析/修改 opencode 消息流、tool UI、permission UI。
|
||||||
|
- [ ] selector 失效时不阻塞主流程,回退 host chrome chips。
|
||||||
|
|
||||||
|
## 6. 安全与权限
|
||||||
|
|
||||||
|
- opencode 只监听 `127.0.0.1`;MVP iframe 直连本机地址,同源反代/API 仍必须受 MNote 登录态保护。
|
||||||
|
- 生产/长期运行必须设置 `OPENCODE_SERVER_PASSWORD`,或改为完整同源反代 + MNote 反代层隔离。
|
||||||
|
- opencode working directory 优先指向当前 workspace root。
|
||||||
|
- MNote allowed roots 映射到 opencode permission:
|
||||||
|
- 当前 workspace root:允许读,写按用户授权。
|
||||||
|
- 当前页文件:允许读写。
|
||||||
|
- workspace 外路径:默认 deny,必要时显式 `external_directory`。
|
||||||
|
- 不使用 `--dangerously-skip-permissions` 作为默认路径。
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
### 7.1 UI
|
||||||
|
|
||||||
|
- Page AI 面板内显示 opencode 官方 WebUI。
|
||||||
|
- 官方消息流、tool 卡片、permission 交互、diff/session UI 尽量原样保留。
|
||||||
|
- MNote 只在 iframe 外展示 host chrome,不重做 opencode UI。
|
||||||
|
- opencode 产生的 changed files 可以用 MNote 主编辑区或资源 tab 打开。
|
||||||
|
|
||||||
|
### 7.2 Runtime
|
||||||
|
|
||||||
|
- 可创建/恢复 opencode session。
|
||||||
|
- 可用 opencodego 模型完成真实回复。
|
||||||
|
- 流式回复浏览器可见。
|
||||||
|
- 权限审批走 opencode 原生机制。
|
||||||
|
- 文件修改后 MNote 当前页面能刷新。
|
||||||
|
|
||||||
|
### 7.3 代码收敛
|
||||||
|
|
||||||
|
- `sidebar-page-ai-runtime.js` 不再承载 Reasonix/ZCode/Hermes/Board provider 状态机。
|
||||||
|
- 不新增 MNote 自研聊天 message store。
|
||||||
|
- 不 fork opencode WebUI,除非 spike 证明 iframe 方案不可用。
|
||||||
|
- 不新增 Page AI Leptos island;Page AI 是 mnote-web sidebar host runtime。
|
||||||
|
|
||||||
|
## 8. 与旧方案对比
|
||||||
|
|
||||||
|
| 方案 | 优点 | 主要问题 | 当前结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Board-first | 可接多 worker/workflow | MNote 仍要维护产品壳和 Board adapter,聊天体验不成熟 | 废弃为主路径 |
|
||||||
|
| CodexMobile embed | Codex 体验强,贴近现有 Codex 体系 | 需要 fork/精简/修 bug,维护派生产品 | 备胎/对照 |
|
||||||
|
| opencode WebUI embed | 官方 runtime + 官方 WebUI + 官方 API,维护成本低 | opencode 能力可能不如 Codex 先进,嵌入细节需实测 | 当前主路径 |
|
||||||
|
|
||||||
|
## 9. 非目标
|
||||||
|
|
||||||
|
- 不重写 opencode WebUI。
|
||||||
|
- 不把 opencode WebUI 拆成 MNote 原生组件。
|
||||||
|
- 不同时接入 Reasonix/ZCode/Hermes/CodexMobile 多后端。
|
||||||
|
- 不把 Agent Board 控制台嵌入 Page AI。
|
||||||
|
- 不在第一版实现完整 MNote SSO 到 opencode;先由 MNote 反代保护。
|
||||||
|
|
||||||
|
## 10. 退出条件
|
||||||
|
|
||||||
|
只有出现以下任一情况,才重新启用 CodexMobile 或自研 UI 方案:
|
||||||
|
|
||||||
|
- opencode WebUI 无法稳定 iframe/反代嵌入。
|
||||||
|
- opencode session 无法通过 API 定位并打开指定 session。
|
||||||
|
- opencode 无法可靠编辑 MNote workspace 文件。
|
||||||
|
- opencode permission/diff/event 无法满足 MNote 最小安全闭环。
|
||||||
|
- opencodego/provider 链路在真实使用中明显不稳定且短期不可修。
|
||||||
|
|
||||||
|
|
||||||
|
## 9. 2026-06-24 iframe 主路径验证记录
|
||||||
|
|
||||||
|
- [x] `opencode --version`:`1.17.9`。
|
||||||
|
- [x] `opencode serve --hostname=127.0.0.1 --port 4096 --print-logs`:真实可启动;官方 WebUI URL `http://127.0.0.1:4096/L21udC9EYXRhMVQvbW5vdGU/session` 可显示 `Build anything`。
|
||||||
|
- [x] `npm run dev:hot`:真实拉起 `mnote-web :3000` 和 `opencode :4096`。
|
||||||
|
- [x] 浏览器 smoke:登录 `mnote.e2e@example.com` 后打开 Page AI,iframe URL 为 `http://127.0.0.1:3000/L21udC9EYXRhMVQvbW5vdGU/session`,显示官方 opencode WebUI;截图 `/tmp/mnote-page-ai-opencode-iframe.png`。
|
||||||
|
- [partial] 发送真实消息:官方 WebUI 可输入并进入 `Thinking/Stop` 运行态;截图 `/tmp/mnote-page-ai-opencode-send.png`。本轮未等待到最终回复,不能声明 provider 回复完成。
|
||||||
|
- [x] binding 持久化 smoke:浏览器 reload 后仍恢复同一 session `ses_106555512ffe5wDz9eMlP4v2i2`。
|
||||||
|
- [x] 静态检查:`node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`。
|
||||||
|
- [x] Rust 检查:`cd rust && cargo check -p mnote-web`。
|
||||||
|
|
||||||
|
剩余 gap:
|
||||||
|
- [x] 在同源反代层补最小 `postMessage` bridge:`open-file / refresh-file / insert-text`,不接管消息流。
|
||||||
|
- [partial] 更完整核对 opencode `/event` 的 file/diff payload:已递归解析常见 changed/diff/file payload 并由 event 触发有界 refresh;仍需真实编辑文件后补最终证据。
|
||||||
|
- [ ] 若要局域网访问,继续确认所有 opencode WebUI root-level API 路由都已被 MNote 登录态反代覆盖,避免直接暴露 4096。
|
||||||
|
|
||||||
|
|
||||||
|
## 10. 2026-06-25 bridge / receipt 补充记录
|
||||||
|
|
||||||
|
- [x] HTML 反代注入极小 bridge:只处理 `insert-text / open-file / refresh-file / session-ready`,不接管 opencode 消息流、tool UI、permission UI。
|
||||||
|
- [x] MNote host chrome 新增“插入当前页上下文”按钮,通过 `postMessage` 把当前页标题、路径、选区、allowed roots 插入 opencode 官方输入框。
|
||||||
|
- [x] 浏览器 smoke:`/tmp/mnote-page-ai-opencode-bridge-final.png`,验证 iframe 内 `window.__mnoteOpencodeBridgeInstalled === true`,点击 host 按钮后官方输入框出现 `MNote 当前页上下文标题:主页`。
|
||||||
|
- [x] postMessage smoke:模拟 iframe 发 `open-file / refresh-file`,确认 MNote host 调用 `openResourceInActiveTab()` 与 `refreshPrimaryDocument()`;输出见 `tmp/mnote-opencode-postmessage-smoke.cjs` 运行结果。
|
||||||
|
- [x] event receipt 强化:`pageAiOpencodeNormalizeChangedFiles()` 改为递归收集 `changedFiles / files / diff / changes / edited / created / deleted / data / properties`,事件到达时先更新 chips,再做有界 projection refresh。
|
||||||
|
- [x] 静态检查:`node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`。
|
||||||
|
- [x] Rust 检查:`cd rust && cargo check -p mnote-web`。
|
||||||
|
|
||||||
|
剩余 gap:
|
||||||
|
- [ ] 让 opencode 真实修改一个测试 Markdown 文件,等待 `/event` + `/session/:id/diff` 产出 changed file chips,再截图验证 chip 点击打开 MNote 主编辑区。
|
||||||
|
- [ ] 若 opencode 官方 WebUI 后续 CSP 变化,需要把 inline bridge 改成 nonce/hash 或外部小脚本。
|
||||||
|
|
||||||
|
## 11. 2026-06-25 opencode 用户隔离与工作目录结论
|
||||||
|
|
||||||
|
### 11.1 当前结论
|
||||||
|
|
||||||
|
- [x] Page AI 工作目录应简化为“当前用户打开的根文件夹”:MNote 当前只能打开一个大的本地根目录,`rootUri` 对应的真实目录就是 opencode `directory` / project directory。
|
||||||
|
- [x] 切换根文件夹应视为新的 opencode session 边界:同一用户同一根目录内可按页面恢复 binding;根目录变化时默认新建 session,不把旧 session 带到新根目录。
|
||||||
|
- [x] MNote 自身 binding 已按 `user_id + workspace_id + mnote_session_id + provider` 隔离;`mnote_session_id` 内包含 workspace/page/directory,因此同一用户跨根目录不会复用同一 binding。
|
||||||
|
- [partial] opencode 自身默认实例没有 MNote 用户概念;如果所有 MNote 用户共用一个 `opencode serve`,opencode 的 session、permission、credential、account、skill、MCP、snapshot、tool-output 会共用同一套本机状态。
|
||||||
|
|
||||||
|
### 11.2 opencode 1.17.9 真实机制证据
|
||||||
|
|
||||||
|
本机 spike 使用临时环境启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp/.../home \
|
||||||
|
XDG_CONFIG_HOME=/tmp/.../config \
|
||||||
|
XDG_DATA_HOME=/tmp/.../data \
|
||||||
|
XDG_CACHE_HOME=/tmp/.../cache \
|
||||||
|
XDG_STATE_HOME=/tmp/.../state \
|
||||||
|
opencode serve --port 4197 --hostname 127.0.0.1 --print-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
观察结果:
|
||||||
|
|
||||||
|
- 配置读取路径变为 `$XDG_CONFIG_HOME/opencode/{config.json,opencode.json,opencode.jsonc}`。
|
||||||
|
- 持久数据写入 `$XDG_DATA_HOME/opencode/opencode.db`。
|
||||||
|
- 日志写入 `$XDG_DATA_HOME/opencode/log/opencode.log`。
|
||||||
|
- 锁写入 `$XDG_STATE_HOME/opencode/locks/*`。
|
||||||
|
- 当前全局实例的默认持久库是 `/home/lix/.local/share/opencode/opencode.db`。
|
||||||
|
- `opencode.db` 内包含 `session`、`message`、`part`、`permission`、`credential`、`account`、`account_state`、`project`、`workspace`、`event` 等表;这些表没有 MNote 用户维度。
|
||||||
|
- `permission` 只按 `project_id + action + resource` 唯一;共用实例会导致不同 MNote 用户在同一 project/directory 下共享 opencode 权限记忆。
|
||||||
|
- `session` 表包含 `directory` / `project_id` / `workspace_id` / `metadata`,但不包含 MNote `user_id`;共用实例不能作为安全隔离边界。
|
||||||
|
- opencode 会从当前用户 HOME/配置路径加载 skill/MCP;未隔离时可看到 `/home/lix/.claude`、`/home/lix/.agents`、`/home/lix/.config/opencode/skill` 等重复 skill 警告。
|
||||||
|
|
||||||
|
### 11.3 推荐隔离方案
|
||||||
|
|
||||||
|
第一版不要试图在单个 opencode server 内实现多用户隔离;改为 **MNote 用户/根目录维度的 opencode runtime profile**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote user + workspace/rootUri
|
||||||
|
-> runtime profile id
|
||||||
|
-> dedicated XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_CACHE_HOME / XDG_STATE_HOME
|
||||||
|
-> dedicated opencode serve process on 127.0.0.1:dynamic_port
|
||||||
|
-> MNote 登录态反代 /page-ai/opencode/...
|
||||||
|
```
|
||||||
|
|
||||||
|
目录建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$MNOTE_DATA/users/<user-id>/opencode/<workspace-hash>/config/opencode/opencode.json
|
||||||
|
$MNOTE_DATA/users/<user-id>/opencode/<workspace-hash>/data/opencode/opencode.db
|
||||||
|
$MNOTE_DATA/users/<user-id>/opencode/<workspace-hash>/cache/opencode/
|
||||||
|
$MNOTE_DATA/users/<user-id>/opencode/<workspace-hash>/state/opencode/
|
||||||
|
```
|
||||||
|
|
||||||
|
配置策略:
|
||||||
|
|
||||||
|
- provider/API key 可由 MNote 管理后写入每个 profile 的最小 `opencode.json`,或在本机单用户 dev 模式从 `/home/lix/.config/opencode/opencode.json` 复制 provider/model 白名单。
|
||||||
|
- skill/MCP 默认不继承宿主 HOME 的全部内容;只显式安装 MNote 允许的 skill/MCP,例如 `codegraph`、`mempalace`、后续 MNote MCP。
|
||||||
|
- opencode 原生 permission 继续保留,但存储在该 profile 的独立 `opencode.db` 中。
|
||||||
|
- MNote 的 allowed roots 仍作为上下文与反代边界;opencode 的工作目录固定为当前打开根目录。
|
||||||
|
|
||||||
|
### 11.4 session 策略
|
||||||
|
|
||||||
|
- 同一 `user_id + workspace_id/rootUri + pageAbsolutePath`:优先恢复 MNote control-plane binding 指向的 opencode session。
|
||||||
|
- `rootUri` 变化:默认新建 session;旧 binding 保留但不跨根目录复用。
|
||||||
|
- `pageAbsolutePath` 变化:默认新建或按页面 binding 恢复,不把旧页面上下文继续注入到新页面。
|
||||||
|
- opencode 原生 session 恢复只作为 profile 内部能力;MNote 是否恢复以 control-plane binding 为准。
|
||||||
|
- 跨浏览器恢复依赖 SQLite control-plane binding + per-user opencode data profile,不依赖 `sessionStorage`。
|
||||||
|
|
||||||
|
### 11.5 后续实现项
|
||||||
|
|
||||||
|
- [ ] 新增 opencode runtime profile manager:按 MNote user/rootUri 分配 XDG 目录、端口、启动/健康检查、生命周期。
|
||||||
|
- [ ] `dev:hot` 继续可一键启动,但默认只启动 dev 单用户 profile;多用户 profile 由后端按需拉起。
|
||||||
|
- [ ] 反代不再只读 `MNOTE_OPENCODE_BASE_URL` 全局值,需按当前登录用户和 rootUri 解析到对应 profile base URL。
|
||||||
|
- [ ] session binding metadata 写入 `runtimeProfileId`、`rootUri`、`projectDirectory`、`opencodeDataDir`,方便审计和恢复。
|
||||||
|
- [ ] 增加最小测试:两个不同 MNote 用户同一 Markdown 根目录下创建 session,不共享 opencode `opencode.db`、permission、session list。
|
||||||
|
|
||||||
|
### 11.6 官方/社区多用户实现调研补充
|
||||||
|
|
||||||
|
本轮先核验豆包给出的项目名,再补充搜索到的真实社区线索。结论:**没有发现可直接嵌入 MNote 的官方/社区“单 opencode 进程多 MNote 用户强隔离”实现**;官方当前也把多用户 Web/serve 部署视为待增强能力。
|
||||||
|
|
||||||
|
豆包列表验真:
|
||||||
|
|
||||||
|
| 项目 | 验真结果 | 对 MNote 的价值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `anomalyco/opencode-orchestrator` | 未找到公开仓库;真实相近项目是 `agnusdei1207/opencode-orchestrator` | 后者是 opencode 多 agent 编排插件,不是多用户 runtime 隔离层 |
|
||||||
|
| `pRizz/opencode-cloud` / `gitea.com/pRizz/opencode-cloud` | 真实存在 | Docker/container 级隔离参考;安全强但本地 MNote 开销偏大 |
|
||||||
|
| `oc-ext/ocx` | 未找到公开仓库 | 暂不可作为依据 |
|
||||||
|
| `daytonaio/daytona-opencode-plugin` | 未找到公开仓库 | 暂不可作为依据 |
|
||||||
|
| `anomalyco/openwork` | 未找到公开仓库 | 暂不可作为依据 |
|
||||||
|
| `lucentia/opencode-svip-proxy` | 未找到公开仓库 | 暂不可作为依据 |
|
||||||
|
| `kwickramasekara/opencode-chat` | 真实存在 | VSCode WebView 嵌入参考:启动/复用 `opencode serve`、固定端口保留 localStorage、WebView proxy、剪贴板/键盘 bridge;不是多用户隔离实现 |
|
||||||
|
|
||||||
|
额外发现:
|
||||||
|
|
||||||
|
| 项目/线索 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 官方 `anomalyco/opencode` issue `#20067` | open:请求 `opencode web` 支持 multi-user auth 与 per-user provider credentials;issue 描述确认共享 Web 实例会共享身份、session、provider credentials |
|
||||||
|
| 官方 `anomalyco/opencode` issue `#5784` | closed:请求多租户 `serve` 下 MCP auth/config;说明多租户 MCP/资源隔离是社区真实痛点,但不是已可用的完整 MNote 用户隔离方案 |
|
||||||
|
| 官方 `SECURITY.md` | 明确 opencode 不提供安全 sandbox;server mode 只支持 `OPENCODE_SERVER_PASSWORD` Basic Auth;需要真隔离时建议 Docker/VM |
|
||||||
|
| `millerjes37/opencode-multiplexer` | 真实存在,是 opencode fork 的 multi-client server 支持;文档承认“知道 sessionID 即可交互”的 session hijacking 风险,session ownership 仍是 future enhancement;不适合作 MNote 多用户隔离底座 |
|
||||||
|
| `joeyism/opencode-multiplexer` | 真实存在,是多 session/多项目终端 dashboard,不是 Web 多用户隔离 |
|
||||||
|
|
||||||
|
对当前方案的修正:
|
||||||
|
|
||||||
|
- 不应引入豆包描述的“单进程多租户 opencode”作为近期主路径;目前没有可靠公开实现,官方也还在 issue 层面。
|
||||||
|
- 也不应一开始上 Docker/container;它解决安全隔离但会显著增加本地笔记软件的启动、资源和运维成本。
|
||||||
|
- 近期最稳妥路线是 **单 opencode 进程 + 单 MNote 当前用户/当前根目录 profile**,先满足本机单用户和局域网同一登录用户;等 MNote 真正进入多用户同时在线场景,再升级为按需 profile manager。
|
||||||
|
- 若要减少端口/生命周期复杂度,可先采用 `opencode-chat` 的轻量做法:固定一个 dev/local 端口、优先复用已存活 server、MNote 反代统一入口;不要提前实现多实例调度。
|
||||||
|
- 多用户隔离仍必须作为设计约束保留:不能把共享 opencode `opencode.db` 声称为安全隔离,只能标为单用户/dev 模式。
|
||||||
|
|
||||||
|
更新后的分阶段建议:
|
||||||
|
|
||||||
|
1. **Phase MVP-local**:一个 MNote 登录用户 + 一个当前根目录 + 一个 opencode server;工作目录固定为 `rootUri`;MNote control-plane 做跨浏览器 session binding。
|
||||||
|
2. **Phase shared-device**:为每个 MNote 用户准备独立 XDG profile,但不常驻多进程;登录/打开 Page AI 时按需启动,空闲回收。
|
||||||
|
3. **Phase SaaS/团队**:再评估 `opencode-cloud`/Docker 或等待官方 multi-user auth/per-user credentials 落地;不要自己 fork 官方 opencode 做单进程多租户。
|
||||||
|
|
||||||
|
### 11.7 OpenHub 对照结论
|
||||||
|
|
||||||
|
`xcl1989/OpenHub` 是目前找到的最接近“opencode 多用户平台”的社区实现,README 明确主张:一个 `opencode serve (:4096)`,后端按用户 workspace 通过 `?directory=` 路由到不同目录,并在应用 SQLite 中维护 users、sessions、messages、permissions、skills、tools 等业务层权限。
|
||||||
|
|
||||||
|
可复用点:
|
||||||
|
|
||||||
|
- 单 opencode server + per-user workspace:后端调用 `/session`、`/session/{id}/prompt_async`、`/global/event` 时统一带 `directory=<user workspace>`。
|
||||||
|
- 应用层 session ownership:OpenHub 自己用 SQLite 记录 `conversation_sessions` / messages / user_id,不把 opencode 原生 session 列表直接暴露给所有用户。
|
||||||
|
- 应用层权限面板:模型权限、工具权限、skill 权限都在业务 DB 中维护,再同步/注入到用户 workspace。
|
||||||
|
- per-user `.opencode` 目录:README 架构图显示每个 workspace 下有独立 `.opencode/skills`、`.opencode/tools`。
|
||||||
|
- 单进程运维简单:固定 `OPENCODE_BASE_URL=http://127.0.0.1:4096`,Basic Auth 保护后端到 opencode 的内部访问。
|
||||||
|
|
||||||
|
关键风险:
|
||||||
|
|
||||||
|
- OpenHub 不是 opencode 原生多租户;它仍依赖一个全局 opencode server 和全局 opencode 数据库/credential/account 状态。
|
||||||
|
- 隔离主要靠 `directory` 与 OpenHub 后端不暴露跨用户 session;如果绕过 OpenHub 直接访问 opencode,或知道别人的 session id,仍要依赖外层鉴权/反代拦截。
|
||||||
|
- provider credentials 是 opencode server 全局配置;OpenHub 的用户模型/工具权限是应用层控制,不等于 opencode 内核 per-user credentials。
|
||||||
|
- 它自研了聊天前端、消息库、知识/记忆/任务系统;这不符合 MNote 当前“尽量保留 opencode 官方 WebUI,不复刻消息 UI”的边界。
|
||||||
|
|
||||||
|
对 MNote 的启发:
|
||||||
|
|
||||||
|
- OpenHub 证明“单 opencode serve + `?directory=` 按用户工作区隔离”在产品上可跑,比一开始做多进程 profile manager 更轻。
|
||||||
|
- MNote MVP 可以采用 OpenHub 的轻量隔离思路:固定一个本机 opencode server,所有请求由 MNote 登录态反代,MNote 后端只允许当前用户的 `rootUri` 作为 `directory`,并用 control-plane binding 限制 session ownership。
|
||||||
|
- 但必须把这种模式标为 **应用层隔离 / 单机可信 opencode 后端**,不能标为强安全多租户。强隔离仍需后续 XDG profile 或 Docker。
|
||||||
|
|
||||||
|
更新后的推荐:
|
||||||
|
|
||||||
|
1. **立即采用 OpenHub-lite**:单 `opencode serve`、固定端口、MNote 反代、`directory=rootUri`、control-plane session ownership。
|
||||||
|
2. **不复刻 OpenHub UI**:仍保留 opencode 官方 WebUI iframe;MNote 只做 host chrome、context、open/refresh、changed files。
|
||||||
|
3. **补安全闸**:所有 `/api/page-ai/opencode/*` 和 iframe 反代必须校验当前登录用户、rootUri、session binding;不允许前端任意传 directory 打开非当前 root。
|
||||||
|
4. **后续 shared-device 再升级**:当确实有多 MNote 用户同时使用同一机器时,再做 per-user XDG profile manager。
|
||||||
|
|
||||||
|
### 11.8 OpenHub 融合可行性评估
|
||||||
|
|
||||||
|
用户新判断:OpenHub 的前端、消息库、权限、记忆、文件、任务等功能与 MNote Page AI 长期目标高度重合,应评估是否直接融合,减少 MNote 自研量。
|
||||||
|
|
||||||
|
结论:**可融合,但不建议整套 OpenHub 作为 MNote 新后端;推荐抽取 OpenHub Page-AI 子系统,形成 MNote 内的 `OpenHub-lite`。**
|
||||||
|
|
||||||
|
可最大化复用的部分:
|
||||||
|
|
||||||
|
- **React/AntD 聊天前端**:`SmartQueryPage.jsx`、`ChatInput`、`AssistantMessage`、`ToolCall`、`QuestionForm`、`HistoryDrawer`、`DiffViewer`、`FileManager`、`GitTimeMachine` 等,可作为 Page AI 的 micro frontend,而不是继续维护当前简陋 host UI。
|
||||||
|
- **消息库模型**:`conversation_sessions`、`conversation_messages`、图片、turn、opencode message id、归档、retry、last-turn delete 等,适合迁移到 MNote control-plane,替代 `sessionStorage` 和当前临时 binding。
|
||||||
|
- **opencode 单进程接入模式**:后端按用户 workspace/rootUri 调 `/session`、`/session/{id}/prompt_async`、`/global/event` 并带 `directory=`,适合 MNote 当前“一个打开根目录”的简化模型。
|
||||||
|
- **应用层权限面板**:模型权限、工具权限、skill 权限、usage 统计可以映射到 MNote 用户体系;短期先只做 Page AI 所需的模型/工具/skill 白名单。
|
||||||
|
- **任务/团队/记忆模块**:Smart Entity、Team、Memory、Scheduler 与 MNote 长期 agent 目标相关,但第一阶段只作为后续模块,不应阻塞 Page AI MVP。
|
||||||
|
|
||||||
|
不建议直接搬入的部分:
|
||||||
|
|
||||||
|
- 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 不是 opencode 内核级强隔离,仍需 MNote 反代和 session ownership 限制。
|
||||||
|
|
||||||
|
推荐融合路线:
|
||||||
|
|
||||||
|
1. **Phase 1:iframe micro frontend spike**
|
||||||
|
- 直接运行 OpenHub 前端的 Page-AI/Chat 子集,嵌入 MNote sidebar。
|
||||||
|
- 后端 API 不直接用 OpenHub FastAPI,而是由 MNote 提供兼容 `/api/query/stream`、`/api/sessions/*`、`/api/files/*` 的最小 Rust adapter。
|
||||||
|
- 目标是快速验证 UI/消息体验是否明显优于 opencode 官方 iframe。
|
||||||
|
|
||||||
|
2. **Phase 2:消息库迁移**
|
||||||
|
- 在 MNote control-plane 增加 OpenHub-like `page_ai_sessions` / `page_ai_messages` / `page_ai_message_parts` / `page_ai_turns`。
|
||||||
|
- 将 opencode session id、message id、tool calls、diff、reasoning、attachments、rootUri、pageAbsolutePath 统一持久化。
|
||||||
|
- 替代当前临时 Page AI binding;支持跨浏览器、跨会话恢复。
|
||||||
|
|
||||||
|
3. **Phase 3:UI 组件裁剪融合**
|
||||||
|
- 从 OpenHub 前端抽出 Chat shell、消息列表、工具调用、历史抽屉、Diff/File/GitTimeMachine 组件。
|
||||||
|
- 去掉 Login/Admin/Knowledge/SmartEntity/Team 等非 Page AI 首屏模块。
|
||||||
|
- 适配 MNote host chrome、当前页 context、changed file chip、MNote open/refresh。
|
||||||
|
|
||||||
|
4. **Phase 4:高级能力选择性引入**
|
||||||
|
- FileManager 映射 MNote resource/file tree。
|
||||||
|
- GitTimeMachine 映射 MNote changed files / snapshot / restore 设计。
|
||||||
|
- Memory/Skill/Tool permission 映射 MNote 用户权限和未来 MNote MCP。
|
||||||
|
- Smart Entity/Team 作为 Page AI 后续 agent team,不进入当前 MVP。
|
||||||
|
|
||||||
|
技术判断:
|
||||||
|
|
||||||
|
- 如果目标是“尽快有成熟 Page AI UI”,OpenHub 前端比 opencode 官方 iframe 更适合深度定制,因为它已经是普通 React/AntD 应用,消息、工具、历史、文件、diff 都在前端组件内。
|
||||||
|
- 如果目标是“最少维护债”,opencode 官方 iframe 仍最省事,但 MNote 与页面/文件/权限/历史的融合会受 iframe 限制。
|
||||||
|
- 当前更适合改为 **OpenHub UI + MNote Rust adapter + opencode runtime**:UI 和消息体验复用 OpenHub,用户/文件/权限/工作区真相仍归 MNote,agent runtime 仍归 opencode。
|
||||||
|
|
||||||
|
新的建议:
|
||||||
|
|
||||||
|
- 把 `7-65` 当前 iframe 方案降级为 runtime spike 与 fallback。
|
||||||
|
- 新增或接续设计 `7-67-openhub-page-ai-fusion-v1`,目标是用 OpenHub Chat 子系统替代当前 Page AI UI。
|
||||||
|
- 第一阶段只做 Chat/Session/Message/Diff/File open 五件事,不引入 OpenHub 登录/admin/知识库/team。
|
||||||
|
### 11.9 OpenHub 知识库实现与 WeKnora 对照
|
||||||
|
|
||||||
|
用户最新判断:Page AI 要做深度融合;MNote 当前已放弃 LightRAG,知识库方向原计划是 WeKnora。因此需要单独核验 OpenHub 自带知识库是否能替代 WeKnora。
|
||||||
|
|
||||||
|
源码核验结论:**OpenHub 自带知识库是轻量 SQLite 文本知识库,不是完整 RAG/知识库底座;适合复用 UI、API 形状和 prompt 注入链路,不建议替代 WeKnora。**
|
||||||
|
|
||||||
|
OpenHub 知识库真实实现:
|
||||||
|
|
||||||
|
- 数据表只有 `knowledge_bases` 与 `knowledge_sources`:字段包括 `scope=enterprise/user`、`owner_id`、`title`、`source_type`、`content`、`tags`、统计字段;没有 chunk 表、embedding 表、向量库、图谱或 citation 表。
|
||||||
|
- 上传解析支持 `.md/.txt/.pdf/.docx/.xlsx/.csv`:PDF 走 PyMuPDF 文本抽取,DOCX 走 python-docx 段落抽取,表格转文本行;没有 OCR、版面恢复、图片解析或复杂文档结构保真。
|
||||||
|
- `chunker.py` 存在 Markdown/文本/表格切块逻辑,但当前知识库主链没有把 chunk 持久化到 DB,检索与注入仍围绕整份 `knowledge_sources.content`。
|
||||||
|
- 检索分两层:DB 层用 `LIKE` 关键字筛出候选;服务层再对候选全文做 CJK/英文 token 的 BM25 + TF-IDF 重排;没有 embedding、semantic search、rerank model、hybrid vector search。
|
||||||
|
- 注入方式是 prompt stuffing:小型个人知识库全量或近似全量注入,大型个人知识库取 2 条结果,企业知识库最多取 1 条结果,每条截取相关片段,总上下文默认限制约 1200 字符。
|
||||||
|
- opencode 集成点是在发送用户问题前构造 `<context>...</context>`,并提示模型如果上下文不足就调用 `knowledge_knowledge_search` 工具继续查。
|
||||||
|
- 前端 `KnowledgeManager.jsx` 和 admin 企业知识库 UI 可直接参考:列表、搜索、上传、添加、编辑、删除、统计、企业只读提示这些产品能力与 MNote 需要高度重合。
|
||||||
|
|
||||||
|
与 WeKnora 的关系:
|
||||||
|
|
||||||
|
- WeKnora 应继续作为 MNote 长期知识库底座候选:负责文档解析、索引、检索、召回、引用、权限过滤与跨文档问答。
|
||||||
|
- OpenHub 知识库不应替代 WeKnora;它更像“用户短记忆/轻量知识片段/企业公告文本”的 fallback。
|
||||||
|
- 最优融合方式是 **OpenHub Knowledge UI + MNote Rust 知识库 adapter + WeKnora provider**:前端交互复用 OpenHub,后端接口形状兼容 OpenHub,但真正的 ingestion/search/citation 由 MNote 调 WeKnora。
|
||||||
|
- OpenHub 的 `knowledge_sources` schema 可以作为 MNote control-plane 的 source registry 参考,但需要增加 `workspace_id/root_uri/resource_id/source_uri/provider_doc_id/index_status/permission_scope/citation_locator` 等 MNote 字段。
|
||||||
|
- OpenHub 的 prompt 注入链路可以短期复用为 Page AI context block,但 WeKnora 命中结果必须带 citation/open-reference 映射,不能只塞纯文本。
|
||||||
|
|
||||||
|
对 7-67 深度融合设计的影响:
|
||||||
|
|
||||||
|
1. Page AI 主 UI 继续选 OpenHub Chat 子系统,而不是官方 opencode iframe。
|
||||||
|
2. Knowledge 模块第一阶段只迁移 UI 与 API contract,不迁移其 SQLite 文本检索为长期底座。
|
||||||
|
3. MNote Rust adapter 提供 OpenHub-compatible `/api/knowledge/*`,内部走 WeKnora 或本地 fallback。
|
||||||
|
4. 保留 OpenHub 轻量知识库作为“未配置 WeKnora 时的 local fallback / 用户手工短知识”,但不能称为默认知识库主线。
|
||||||
|
5. 新设计稿应明确:`OpenHub UI` 负责交互,`MNote control-plane` 负责用户与权限,`WeKnora` 负责知识库索引与检索,`opencode` 负责 agent 执行。
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 7-66 [process] Page AI opencode-native UI v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-24
|
||||||
|
>
|
||||||
|
> 当前状态:`RECYCLED / 失败方向,仅保留为反例`
|
||||||
|
>
|
||||||
|
> 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。
|
||||||
|
|
||||||
|
因此 `7-66` 只作为反例保留:native message timeline / composer / permission cards 可以临时作为 debug receipt,但不得成为 Page AI 主 UI,也不继续扩写。当前主线回到 `7-65`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI = MNote host chrome + opencode 官方 WebUI iframe + MNote open/refresh/changed files receipt
|
||||||
|
opencode = 官方聊天 UI、session、tool、permission、diff、model/provider
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 参考结论
|
||||||
|
|
||||||
|
### 2.1 opencode 官方 VSCode 插件
|
||||||
|
|
||||||
|
官方 VSCode 插件 `sst-dev.opencode` 的公开说明更偏 IDE 集成而不是完整 WebUI 复刻:
|
||||||
|
- 快捷键启动/聚焦 opencode terminal session。
|
||||||
|
- 新建 opencode terminal session。
|
||||||
|
- 自动共享当前 selection/tab。
|
||||||
|
- 支持 `@File#L37-42` 文件引用。
|
||||||
|
|
||||||
|
这说明官方 IDE 路线不是“把 WebUI 完整 iframe 到 IDE”,而是让宿主 IDE 负责上下文、入口和文件引用,opencode runtime 负责 agent。
|
||||||
|
|
||||||
|
### 2.2 社区 VSCode WebUI 插件
|
||||||
|
|
||||||
|
社区方案集中在两类:
|
||||||
|
- 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 打开文件/刷新编辑器。
|
||||||
|
|
||||||
|
## 3. 必须覆盖的 opencode 功能
|
||||||
|
|
||||||
|
### Phase A:可用会话壳
|
||||||
|
|
||||||
|
- [ ] session list / create / resume / current binding。
|
||||||
|
- [ ] 当前 session 状态:agent、model/provider、token/cost、idle/running/error。
|
||||||
|
- [ ] composer:发送 prompt、排队/运行状态、abort。
|
||||||
|
- [ ] context bar:当前页、真实 Markdown 路径、selection、allowed roots。
|
||||||
|
|
||||||
|
### Phase B:消息与 parts
|
||||||
|
|
||||||
|
- [ ] user / assistant / system / synthetic 区分展示。
|
||||||
|
- [ ] 隐藏 MNote context envelope 噪声,但保留“上下文已注入”状态。
|
||||||
|
- [ ] text part 正文渲染。
|
||||||
|
- [ ] reasoning part 折叠展示。
|
||||||
|
- [ ] tool part 卡片:tool 名、状态、输入/输出摘要。
|
||||||
|
- [ ] patch part / file part / shell part / agent/subtask part 以卡片展示。
|
||||||
|
- [ ] message error、finish、tokens/cost 展示。
|
||||||
|
|
||||||
|
### Phase C:权限、问题、diff
|
||||||
|
|
||||||
|
- [ ] permission.asked 列表:allow once / always / reject。
|
||||||
|
- [ ] question request:文本输入回复 / reject。
|
||||||
|
- [ ] changed files chips:点击用 MNote 打开。
|
||||||
|
- [ ] diff summary:新增/修改/删除数量;当前页命中后刷新编辑器。
|
||||||
|
- [ ] abort / revert / unrevert 留出按钮,但 MVP 可先只接 abort。
|
||||||
|
|
||||||
|
### Phase D:事件驱动刷新
|
||||||
|
|
||||||
|
- [ ] 订阅 `/api/page-ai/opencode/events`。
|
||||||
|
- [ ] 处理 `message.updated`、`message.part.updated`、`message.part.delta`、`session.next.*`、`permission.v2.asked/replied`、`session.diff`、`session.error`。
|
||||||
|
- [ ] 不高频轮询;事件只触发有界 refresh:messages / permissions / diff。
|
||||||
|
|
||||||
|
## 4. MNote 特有集成
|
||||||
|
|
||||||
|
- 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。
|
||||||
|
|
||||||
|
## 5. 第一版验收
|
||||||
|
|
||||||
|
- 打开 Page AI 看到 MNote-native opencode 面板,不出现 iframe 空壳。
|
||||||
|
- 能创建/恢复 session。
|
||||||
|
- 能发送真实消息,看到 user message 和 assistant/text/tool/reasoning/patch 卡片之一。
|
||||||
|
- 能看到 session list、model/provider、changed files、permission 区域。
|
||||||
|
- 如 opencode/provider 出错,UI 显示真实错误,不伪装成功。
|
||||||
|
- 浏览器截图自检通过后再汇报。
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
> 状态补充(2026-06-25):本稿为 OpenHub 初步融合稿;更完整的 OpenHub + WeKnora + MNote 取舍与实施主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。
|
||||||
|
|
||||||
|
# 7-67 OpenHub Page AI 深度融合设计 v1
|
||||||
|
|
||||||
|
状态:process
|
||||||
|
Owner:07-ai / mnote-web / control-plane
|
||||||
|
日期:2026-06-25
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
`7-65` 的 opencode 官方 WebUI iframe 路线验证了 opencode runtime、同源反代、session binding、文件打开/刷新等接缝,但 UI 深度融合受 iframe 与官方 WebUI 结构限制。用户目标已经调整为:尽量复用成熟社区项目 OpenHub 的前端、消息库、权限、知识库 UI 和 opencode 接入模式,把 MNote Page AI 做成类似 VSCode + Cline 的一体化侧边栏,而不是 MNote 自研一个简陋聊天框。
|
||||||
|
|
||||||
|
当前 Page AI 新主路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Rust SSR / control-plane / local workspace
|
||||||
|
-> OpenHub Chat 子系统 UI(裁剪融合)
|
||||||
|
-> MNote Rust adapter(兼容 OpenHub API 形状)
|
||||||
|
-> opencode serve runtime
|
||||||
|
-> WeKnora knowledge provider(长期知识库底座)
|
||||||
|
```
|
||||||
|
|
||||||
|
`7-65` 降级为 opencode runtime spike / fallback;不再把官方 opencode WebUI iframe 作为默认产品 UI。
|
||||||
|
|
||||||
|
## 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 自带 SQLite 文本知识库当作 MNote 长期知识库底座;长期知识库 provider 是 WeKnora。
|
||||||
|
- 不使用 `--dangerously-skip-permissions` 作为默认路径。
|
||||||
|
- 不把 opencode 原生 session 列表裸露给前端;所有 session 必须绑定 MNote 用户、rootUri、page path。
|
||||||
|
- 不通过高频轮询刷新消息或 changed files;优先使用 opencode event stream、MNote watcher、programmatic refresh。
|
||||||
|
|
||||||
|
## 3. OpenHub 可复用资产
|
||||||
|
|
||||||
|
### 3.1 前端组件
|
||||||
|
|
||||||
|
第一阶段优先复用并裁剪:
|
||||||
|
|
||||||
|
- `SmartQueryPage.jsx`:聊天 shell、消息流、状态管理、移动端布局参考。
|
||||||
|
- `ChatInput.jsx`:输入框、附件、快捷操作、发送体验。
|
||||||
|
- `AssistantMessage.jsx`:Markdown、代码块、tool result、reasoning 展示基础。
|
||||||
|
- `ToolCall.jsx`:工具调用卡片与权限提示参考。
|
||||||
|
- `HistoryDrawer.jsx`:历史会话抽屉。
|
||||||
|
- `DiffViewer.jsx`:变更展示,但文件打开必须接 MNote open-resource。
|
||||||
|
- `FileManager.jsx`:映射 MNote local workspace / file tree。
|
||||||
|
- `KnowledgeManager.jsx`:知识库 UI 参考,后端改接 MNote/WeKnora adapter。
|
||||||
|
|
||||||
|
暂缓融合:
|
||||||
|
|
||||||
|
- OpenHub Login/Admin 整页。
|
||||||
|
- SmartEntity / Team / Scheduler。
|
||||||
|
- GitTimeMachine 的 restore 写入能力;可先只显示 diff / changed files。
|
||||||
|
|
||||||
|
### 3.2 后端模式
|
||||||
|
|
||||||
|
可复用其 API contract 和 opencode 调用模式:
|
||||||
|
|
||||||
|
- `/api/query/stream`
|
||||||
|
- `/api/sessions`
|
||||||
|
- `/api/sessions/{sessionId}/messages`
|
||||||
|
- `/api/knowledge/*`
|
||||||
|
- `/api/files/*`
|
||||||
|
- opencode `/session?directory=<workspace>`、`/session/{id}/prompt_async?directory=<workspace>`、`/global/event?directory=<workspace>`
|
||||||
|
|
||||||
|
但实现落在 Rust `mnote-web` / control-plane,不新增常驻 FastAPI 后端。
|
||||||
|
|
||||||
|
## 4. MNote 目标架构
|
||||||
|
|
||||||
|
### 4.1 UI 层
|
||||||
|
|
||||||
|
`sidebar-page-ai-runtime.js` 不再维护自研聊天消息渲染主链,而是挂载 OpenHub-derived Page AI micro frontend:
|
||||||
|
|
||||||
|
- MNote-native host chrome:当前页、selection、workspace、授权状态、runtime 状态、changed file chips。
|
||||||
|
- OpenHub-derived chat area:消息、tool call、diff、history、input、附件。
|
||||||
|
- Bridge:只处理 MNote 专属动作:`open-file`、`refresh-file`、`insert-context`、`session-ready`、`changed-files`。
|
||||||
|
|
||||||
|
### 4.2 Rust adapter 层
|
||||||
|
|
||||||
|
新增或重构 Page AI API:
|
||||||
|
|
||||||
|
- `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。
|
||||||
|
|
||||||
|
### 4.3 opencode runtime 层
|
||||||
|
|
||||||
|
短期:单 opencode server + 当前打开 rootUri + MNote 用户级 binding。
|
||||||
|
|
||||||
|
中期:按 MNote user/profile 隔离 XDG profile,按需启动、空闲回收。
|
||||||
|
|
||||||
|
多用户强隔离不由 OpenHub 原生保证,必须由 MNote 反代与 profile manager 实现。
|
||||||
|
|
||||||
|
### 4.4 知识库层
|
||||||
|
|
||||||
|
OpenHub 知识库结论:其自带实现是 `knowledge_bases + knowledge_sources + LIKE/BM25/TF-IDF + prompt stuffing`,不是完整 RAG。
|
||||||
|
|
||||||
|
MNote 采用:
|
||||||
|
|
||||||
|
- UI:复用 OpenHub `KnowledgeManager` 交互。
|
||||||
|
- API:提供 OpenHub-compatible `/api/knowledge/*`。
|
||||||
|
- Provider:默认走 WeKnora。
|
||||||
|
- Fallback:未配置 WeKnora 时,可临时用 OpenHub-like SQLite 文本知识源做短知识。
|
||||||
|
- Citation:WeKnora 结果必须保留 source/resource/open-reference 映射,方便点击回 MNote 文件或资源页。
|
||||||
|
|
||||||
|
## 5. 实施阶段
|
||||||
|
|
||||||
|
### Phase A:源码裁剪 spike
|
||||||
|
|
||||||
|
- [ ] 抽取 OpenHub Chat 组件依赖图,确认最小可运行组件集。
|
||||||
|
- [ ] 在 `mnote-web` 静态资源中引入 OpenHub-derived bundle 或独立构建产物。
|
||||||
|
- [ ] 去除 OpenHub 登录/admin 路由依赖,改用 MNote 当前登录态。
|
||||||
|
- [ ] 用静态 fixture 跑出接近 OpenHub 原始体验的 Page AI sidebar。
|
||||||
|
|
||||||
|
### Phase B:OpenHub-compatible session/message API
|
||||||
|
|
||||||
|
- [ ] 增加 MNote control-plane session/message 表。
|
||||||
|
- [ ] 实现 `/api/page-ai/openhub/sessions` 与 `/messages` adapter。
|
||||||
|
- [ ] 绑定 `mnote_user_id + rootUri + pageAbsolutePath + opencode_session_id`。
|
||||||
|
- [ ] 支持跨浏览器恢复同一 MNote 用户的会话。
|
||||||
|
|
||||||
|
### Phase C:真实 opencode streaming
|
||||||
|
|
||||||
|
- [ ] adapter 创建/恢复 opencode session,directory 固定为当前打开 rootUri。
|
||||||
|
- [ ] `/query/stream` 转发到 opencode prompt_async + global event。
|
||||||
|
- [ ] 保存 user/assistant/tool/diff 消息。
|
||||||
|
- [ ] 解析 changed files 并驱动 MNote changed file chips。
|
||||||
|
|
||||||
|
### Phase D:MNote 文件与刷新融合
|
||||||
|
|
||||||
|
- [ ] changed file chip 点击走 `openResourceInActiveTab()`。
|
||||||
|
- [ ] 当前页被修改后调用 `refreshPrimaryDocument()` 或 watcher 刷新链路。
|
||||||
|
- [ ] DiffViewer 中所有 file path 点击都映射到 MNote resource/file open。
|
||||||
|
- [ ] 文件路径必须限制在当前 rootUri / allowed roots 内。
|
||||||
|
|
||||||
|
### Phase E:Knowledge / WeKnora adapter
|
||||||
|
|
||||||
|
- [ ] 兼容 OpenHub `knowledgeService` 的 list/create/upload/search/stats API。
|
||||||
|
- [ ] 后端默认调用 WeKnora ingestion/search。
|
||||||
|
- [ ] 将 WeKnora 命中结果转成 OpenHub UI 可展示的 source/citation。
|
||||||
|
- [ ] 未配置 WeKnora 时启用 SQLite fallback,并在 UI 明确标注 fallback。
|
||||||
|
|
||||||
|
### Phase F:浏览器真实验证
|
||||||
|
|
||||||
|
- [ ] `npm run dev:hot` 一键拉起 MNote + opencode runtime + Page AI UI。
|
||||||
|
- [ ] 登录测试账号后打开真实 Markdown 页面。
|
||||||
|
- [ ] Page AI 看到 OpenHub-derived UI,而不是旧简陋聊天框或官方 iframe。
|
||||||
|
- [ ] 发送真实消息,模型能识别当前 rootUri 内文件。
|
||||||
|
- [ ] 让 opencode 修改测试 Markdown,MNote changed chip 可打开,当前页可刷新。
|
||||||
|
- [ ] Knowledge UI 可上传/检索,WeKnora provider 有真实命中与引用。
|
||||||
|
- [ ] 保存截图与 smoke 输出。
|
||||||
|
|
||||||
|
## 6. 验收标准
|
||||||
|
|
||||||
|
MVP 完成条件:
|
||||||
|
|
||||||
|
- Page AI 主要视觉与交互来自 OpenHub Chat 子系统。
|
||||||
|
- 会话和消息持久化在 MNote control-plane,支持同用户跨浏览器恢复。
|
||||||
|
- opencode 真实流式回复可用,工作目录固定为当前打开 rootUri。
|
||||||
|
- changed files 与 MNote open/refresh 打通。
|
||||||
|
- Knowledge UI 至少能展示 WeKnora-backed 搜索结果;未接 WeKnora 时必须标注 fallback,不得声称知识库主线已完成。
|
||||||
|
- `npm run dev:hot` 后可用真实浏览器截图证明。
|
||||||
|
|
||||||
|
## 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。
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
# 7-68 OpenHub + WeKnora + MNote Page AI 嵌入式集成设计 v1
|
||||||
|
|
||||||
|
状态:process
|
||||||
|
Owner:07-ai / mnote-web / control-plane / knowledge-provider
|
||||||
|
日期:2026-06-25
|
||||||
|
|
||||||
|
## 0. 结论先行
|
||||||
|
|
||||||
|
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 工具调用
|
||||||
|
└─ 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 负责 Page AI 界面与多用户 AI 运行栈**:嵌入 OpenHub AI 面板,运行 FastAPI/Redis,使用 OpenHub 的用户隔离、session、skill、agent/MCP、tool permission、opencode 调用链;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 只做展示、授权和回跳映射。
|
||||||
|
|
||||||
|
## 1. 背景与当前状态
|
||||||
|
|
||||||
|
### 1.1 已知事实
|
||||||
|
|
||||||
|
- OpenHub 是一个基于 opencode 的多用户 AI 平台参考实现,前端 React/AntD 组件较完整,包含 Chat、History、ToolCall、Diff、FileManager、KnowledgeManager 等。
|
||||||
|
- OpenHub 多用户主要是应用层隔离:后端带 `directory=<user workspace>` 调 opencode,自己用 SQLite 管用户、session、message、权限、技能/工具权限,并可能对 workspace 执行 Git snapshot / restore。
|
||||||
|
- OpenHub 自带知识库是轻量 `knowledge_bases + knowledge_sources + 本地全文 content + SQLite LIKE 候选 + Python BM25/TF-IDF rerank + prompt stuffing`,不是完整 RAG 底座;默认注入上下文约 1200 字符。
|
||||||
|
- WeKnora 已部署在本机 `/mnt/Data1T/Mnote_data/weknora/WeKnora`,并提供知识库、知识文件、chunk、`/api/v1/knowledge-search`、knowledge-chat、agent-chat、tenant/RBAC、共享空间、CLI/MCP 等能力。
|
||||||
|
- MNote 当前 active 代码仍大量使用 `knowledge_rag` / `LightRAG` 命名;用户已明确当前放弃 LightRAG,因此需要 provider-neutral 化并切到 WeKnora。
|
||||||
|
- MNote 当前已有 `/api/page-ai/opencode/*` 反代/绑定雏形和 `ai_external_conversation_bindings`;在本路线下应转为 OpenHub host/proxy/session binding 与 artifact/open-reference index,而不是重写 OpenHub FastAPI 的 opencode client。
|
||||||
|
|
||||||
|
### 1.2 本设计覆盖范围
|
||||||
|
|
||||||
|
本设计替代 `7-65` 的官方 opencode iframe 产品主线,并收敛 `7-67` 的 OpenHub 初步融合设想。核心不是重写三套系统,而是保留各自已有能力,只对冲突点做最小胶水:
|
||||||
|
|
||||||
|
1. 登录/用户/权限冲突。
|
||||||
|
2. WeKnora 知识库底座、MNote 展示层、OpenHub MCP/skill/tool 调用边界。
|
||||||
|
3. 页面/文件打开、changed files、citation 回跳边界。
|
||||||
|
|
||||||
|
## 2. 三方职责边界
|
||||||
|
|
||||||
|
| 能力 | 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 |
|
||||||
|
| 文件打开 | 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 防线 |
|
||||||
|
| 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 权限/文件/知识库真相 |
|
||||||
|
|
||||||
|
## 3. 登录、OpenHub 隔离与 WeKnora 授权融合
|
||||||
|
|
||||||
|
### 3.1 核心更正
|
||||||
|
|
||||||
|
这里不能简单写成“只使用 MNote 单一用户认证,然后 OpenHub/WeKnora 都完全无用户”。更准确的模型是保留各自用户/权限机制中有价值的部分,并由 MNote 在边界上做最小嫁接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote 登录态 / user_id / workspace grants
|
||||||
|
├─ OpenHub 派生隔离上下文:openhub_user_key + workspace/runtime/session/skill/MCP 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。
|
||||||
|
- **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 登录体系。
|
||||||
|
|
||||||
|
### 3.2 冲突
|
||||||
|
|
||||||
|
OpenHub 和 WeKnora 的用户模型对 MNote 的影响不同:
|
||||||
|
|
||||||
|
- OpenHub 前端会使用 `auth_token` / JWT / localStorage,并在 401 后跳转到 `/login`。
|
||||||
|
- OpenHub 后端还有会话、消息、skill、MCP、tool/model permission、workspace path 和 Git snapshot 等用户相关状态。
|
||||||
|
- 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、工具审批和文件变更串用户。
|
||||||
|
|
||||||
|
### 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` 共享。
|
||||||
|
- WeKnora 使用 MNote 后端服务 API key 或受控 profile 调用;前端不直接持有 WeKnora API key。
|
||||||
|
- WeKnora KB/source 由 MNote 的 workspace grants、source registry、allowed roots 决定;WeKnora tenant/RBAC 只作为 provider 内部防线。
|
||||||
|
|
||||||
|
### 3.4 映射建议
|
||||||
|
|
||||||
|
短期本机/local-first:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote user_id + workspace_id/rootUri
|
||||||
|
-> OpenHub runtime identity: mnote:{user_id}:{workspace_id}:{root_hash}
|
||||||
|
-> 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 provider profile: mnote-local 服务 API key
|
||||||
|
-> WeKnora KB: mnote-{workspace_id}-{purpose}
|
||||||
|
-> MNote source registry 记录 provider KB / knowledge / chunk 映射
|
||||||
|
```
|
||||||
|
|
||||||
|
中期多用户/局域网:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote user_id + workspace membership
|
||||||
|
-> OpenHub runtime identity 按 user/workspace 派生或映射到受控 OpenHub user
|
||||||
|
-> MNote credential vault 选择 WeKnora service profile/API key
|
||||||
|
-> WeKnora 默认按 workspace/profile/KB 隔离,不强制每个 MNote 用户对应 WeKnora 用户
|
||||||
|
-> 所有 OpenHub 可见性仍由 MNote 登录态 + OpenHub scope 校验
|
||||||
|
-> 所有 WeKnora 结果仍由 MNote source registry / allowed roots 二次过滤
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在第一阶段为每个 MNote 用户强行同步 WeKnora RBAC;这会放大生命周期与权限同步复杂度。相反,第一阶段应优先保证 OpenHub 派生上下文隔离,因为 Page AI 的 session、skill、MCP、工具审批和文件变更都直接依赖 MNote 登录态。
|
||||||
|
|
||||||
|
## 4. OpenHub Session / Message 真相
|
||||||
|
|
||||||
|
### 4.1 核心更正
|
||||||
|
|
||||||
|
这里不应设计“三方 session 融合”,也不应新增一套 MNote `page_ai_messages` 作为 AI 面板消息真相。应保留 OpenHub 已做好的 session/message 能力,只做 MNote ownership binding:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI 面板
|
||||||
|
-> MNote 自有 Page AI 映射 OpenHub session/conversation
|
||||||
|
-> OpenHub session / conversation 是唯一 AI 面板会话真相
|
||||||
|
-> MNote control-plane 只保存绑定、索引和打开/权限映射
|
||||||
|
-> WeKnora 不参与 Page AI 会话真相
|
||||||
|
```
|
||||||
|
|
||||||
|
- **OpenHub session 是 Page AI session 真相**:消息、turn、tool call、skill/MCP 状态、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`。
|
||||||
|
|
||||||
|
### 4.2 冲突
|
||||||
|
|
||||||
|
真正的冲突不是“三套消息历史融合”,而是:
|
||||||
|
|
||||||
|
- OpenHub session/message 本来就是 AI 面板的产品模型,MNote 自建 `page_ai_messages` 会变成第二份聊天真相。
|
||||||
|
- MNote 仍需要知道某个 OpenHub session 属于哪个 `mnote_user_id + workspace_id/rootUri + page_resource_id`,否则无法做跨浏览器恢复、权限过滤和打开文件回跳。
|
||||||
|
- WeKnora 的 agent-chat / knowledge-chat 若混入主链,会引入第二套 provider chat session;当前应明确弃用。
|
||||||
|
|
||||||
|
### 4.3 决策
|
||||||
|
|
||||||
|
MNote control-plane 只新增或复用**绑定/索引层**,不新增消息全文主表。表名可复用现有 `ai_external_conversation_bindings` 并扩展 metadata,不要求一定新建下列物理表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
page_ai_openhub_bindings
|
||||||
|
id
|
||||||
|
mnote_user_id
|
||||||
|
workspace_id
|
||||||
|
root_uri
|
||||||
|
page_resource_id
|
||||||
|
page_absolute_path
|
||||||
|
openhub_user_key
|
||||||
|
openhub_session_id
|
||||||
|
opencode_session_id nullable
|
||||||
|
status = active | archived | stale
|
||||||
|
metadata_json
|
||||||
|
created_at / updated_at / archived_at
|
||||||
|
|
||||||
|
page_ai_artifact_index
|
||||||
|
id
|
||||||
|
binding_id
|
||||||
|
openhub_session_id
|
||||||
|
kind = changed_file | diff | citation | tool_call_ref | attachment_ref
|
||||||
|
provider = openhub | opencode | weknora
|
||||||
|
provider_id
|
||||||
|
payload_json
|
||||||
|
mnote_resource_id nullable
|
||||||
|
mnote_open_reference_json nullable
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`page_ai_artifact_index` 不是消息真相,只是为了 MNote sidebar/document pane 能打开 changed files、diff、citation、attachment。消息正文、历史列表、tool card 展示、retry/隐藏状态仍从 OpenHub session/conversation 读取。
|
||||||
|
|
||||||
|
### 4.4 OpenHub session scope
|
||||||
|
|
||||||
|
OpenHub session 必须绑定 MNote 登录态派生的 scope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
openhub_session_scope = hash(mnote_user_id, workspace_id, root_uri, page_resource_id)
|
||||||
|
openhub_user_key = stable_hash(mnote_user_id)
|
||||||
|
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 boundary 对 OpenHub session 的读写必须先校验 `mnote_session` 与 binding ownership。
|
||||||
|
|
||||||
|
### 4.5 WeKnora session policy
|
||||||
|
|
||||||
|
第一阶段不使用 WeKnora `knowledge-chat` / `agent-chat` 作为 Page AI 会话层:
|
||||||
|
|
||||||
|
- WeKnora 通过 MCP/CLI/API 暴露知识库能力给 OpenHub/opencode 工具链。
|
||||||
|
- WeKnora 检索结果返回 chunk/reference,MNote 负责 source registry 映射和 citation 回跳。
|
||||||
|
- 如果未来启用 WeKnora agent-chat,只能作为 OpenHub tool call 的内部 provider call,不能成为 Page AI 历史会话真相。
|
||||||
|
|
||||||
|
## 5. Knowledge 融合
|
||||||
|
|
||||||
|
### 5.1 OpenHub 知识库定位
|
||||||
|
|
||||||
|
OpenHub `KnowledgeManager` 第一阶段不复用;其后端知识库也不应成为主线:
|
||||||
|
|
||||||
|
- 数据模型只有 base/source,没有持久 chunk/citation/embedding。
|
||||||
|
- 检索是 SQLite `LIKE` 候选 + BM25/TF-IDF 重排。
|
||||||
|
- 注入是 prompt stuffing,总上下文默认约 1200 字符。
|
||||||
|
|
||||||
|
适合:作为 OpenHub 源码理解和对照材料。
|
||||||
|
|
||||||
|
不适合:MNote 知识库主线、fallback 知识库、KnowledgeManager 页面复用、长期资料库、复杂 PDF/图片/OCR、可点击 citation、跨文档图谱、长期 RAG。
|
||||||
|
|
||||||
|
### 5.2 WeKnora 能力定位
|
||||||
|
|
||||||
|
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),不能当原始相似度解释。
|
||||||
|
- 问答:`POST /api/v1/knowledge-chat/:session_id`、`POST /api/v1/agent-chat/:session_id` SSE 作为后续可选 provider 能力;第一阶段 Page AI 主链暂弃用,不产生主会话真相。
|
||||||
|
- 权限:tenant RBAC / shared organization 作内部防线。
|
||||||
|
|
||||||
|
MNote 侧不要把 WeKnora RBAC 当唯一隔离边界:WeKnora RBAC 可能受配置开关影响,关闭时 guard 可能记录但放行;MNote 必须始终按 `mnote_session + workspace/rootUri + source registry + allowed roots` 二次过滤。
|
||||||
|
|
||||||
|
### 5.3 MNote Knowledge Adapter / Search Replacement
|
||||||
|
|
||||||
|
保留 MNote 对外 canonical API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/knowledge-rag/status
|
||||||
|
/api/knowledge-rag/ingest
|
||||||
|
/api/knowledge-rag/search
|
||||||
|
/api/knowledge-rag/query
|
||||||
|
/api/knowledge-rag/section-context
|
||||||
|
/api/knowledge-rag/open-reference
|
||||||
|
/api/knowledge-rag/delete-source
|
||||||
|
/api/knowledge-rag/prune-registry
|
||||||
|
```
|
||||||
|
|
||||||
|
但内部主链从 LightRAG 聚合检索切到 WeKnora。当前 `knowledge_rag.rs` 的 `/api/knowledge-rag/search`、`/api/knowledge-rag/query`、`/api/knowledge-rag/section-context` 仍围绕 LightRAG `/query/search`、`/query/data`、sidecar block、reference mapper、rank/dedupe 展开;替换时不能只改 endpoint,需要把 provider 调用、结果模型、locator 映射和排序语义一起替换。
|
||||||
|
|
||||||
|
目标 adapter:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
trait KnowledgeProvider {
|
||||||
|
fn status(root_uri, workspace_id) -> ProviderStatus;
|
||||||
|
fn ensure_kb(scope) -> ProviderKbRef;
|
||||||
|
fn ingest(source) -> ProviderKnowledgeRef;
|
||||||
|
fn search(query, scope, filters) -> Vec<ProviderReference>;
|
||||||
|
fn query(question, scope, filters) -> ProviderQueryResult;
|
||||||
|
fn section_context(provider_ref, query, scope) -> ProviderSectionContext;
|
||||||
|
fn open_reference(provider_ref) -> MnoteOpenReference;
|
||||||
|
fn delete_source(provider_ref) -> DeleteResult;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 `weknora` provider 实现;旧 LightRAG provider 标记 legacy,不再作为默认。第一阶段 `query` 可以由 WeKnora search results + citations 组成 answer envelope,不启用 WeKnora `knowledge-chat` / `agent-chat` 会话。
|
||||||
|
|
||||||
|
检索替换原则:
|
||||||
|
|
||||||
|
- `/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 `knowledge_id` / `chunk_id` / `knowledge_base_id` 只用于回查 registry,不能直接作为 MNote 文件路径。
|
||||||
|
|
||||||
|
WeKnora search result 到 MNote registry 的最低字段映射:
|
||||||
|
|
||||||
|
| WeKnora 字段 | MNote 派生字段 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `providerChunkId` | WeKnora chunk id |
|
||||||
|
| `knowledge_id` | `providerKnowledgeId` | 回查 registry 的主键之一 |
|
||||||
|
| `knowledge_base_id` | `providerKnowledgeBaseId` | provider KB 映射 |
|
||||||
|
| `chunk_index` | `chunkIndex` | 可用于同一 knowledge 内排序/定位 |
|
||||||
|
| `start_at` / `end_at` | `providerOffsets` | 只能作为 provider 内偏移,不能直接当 Markdown 行号 |
|
||||||
|
| `knowledge_filename` | `providerDisplayName` | 只用于显示,不能当本地路径真相 |
|
||||||
|
| `knowledge_source` / `knowledge_channel` | `providerSourceMeta` | 用于辅助映射和审计 |
|
||||||
|
|
||||||
|
`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 知识库展示层的实现。当前边界是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Knowledge UI(复用 WeKnora KB list/detail/upload/status 体验)
|
||||||
|
-> MNote source set / source registry / allowed roots
|
||||||
|
-> WeKnora ingest / search / status / open-reference
|
||||||
|
|
||||||
|
MNote Page AI / OpenHub-style session
|
||||||
|
-> opencode 按 MCP/skill/tool 规范自行决定 tool call
|
||||||
|
-> 已注册的 `mnote.weknora.*` MCP/CLI/API tool
|
||||||
|
-> WeKnora search/query
|
||||||
|
-> tool result 回 OpenHub 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 回跳。
|
||||||
|
- **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 自己判断。
|
||||||
|
|
||||||
|
第一阶段需要的接口不是 OpenHub-compatible knowledge API,而是三类接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote UI canonical API:
|
||||||
|
/api/knowledge-rag/status
|
||||||
|
/api/knowledge-rag/ingest
|
||||||
|
/api/knowledge-rag/search
|
||||||
|
/api/knowledge-rag/open-reference
|
||||||
|
/api/knowledge-rag/delete-source
|
||||||
|
/api/knowledge-rag/prune-registry
|
||||||
|
|
||||||
|
WeKnora-page-in-MNote adapter:
|
||||||
|
list_kbs / create_kb / update_kb / delete_kb
|
||||||
|
list_sources / add_source_set / upload_or_link_source / reparse_source / delete_source
|
||||||
|
get_processing_status / get_citation_open_reference
|
||||||
|
|
||||||
|
OpenHub/opencode tool facade:
|
||||||
|
mnote.weknora.search
|
||||||
|
mnote.weknora.open_reference
|
||||||
|
mnote.weknora.list_sources
|
||||||
|
mnote.weknora.get_source_status
|
||||||
|
```
|
||||||
|
|
||||||
|
WeKnora 页面 adapter 可以复用 WeKnora 前端组件/交互,但数据入口必须先经过 MNote source registry 与 allowed roots;`mnote.weknora.*` 可以底层走 WeKnora MCP、CLI 或 HTTP API,但对 OpenHub 暴露的合同必须是 MNote 权限过滤后的 tool contract。
|
||||||
|
|
||||||
|
### 5.5 Source Registry provider-neutral 化
|
||||||
|
|
||||||
|
旧 LightRAG 字段应迁移为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mnote_knowledge_sources
|
||||||
|
id
|
||||||
|
user_id
|
||||||
|
workspace_id
|
||||||
|
root_uri
|
||||||
|
resource_id nullable
|
||||||
|
source_uri
|
||||||
|
source_path
|
||||||
|
source_hash
|
||||||
|
provider = weknora | lightrag_legacy | local_fallback
|
||||||
|
provider_kb_id
|
||||||
|
provider_knowledge_id
|
||||||
|
provider_doc_id nullable
|
||||||
|
provider_status
|
||||||
|
index_status = queued | parsing | indexed | failed | stale | deleted
|
||||||
|
title
|
||||||
|
source_type
|
||||||
|
tags_json
|
||||||
|
citation_locator_json
|
||||||
|
metadata_json
|
||||||
|
created_at / updated_at / deleted_at
|
||||||
|
```
|
||||||
|
|
||||||
|
关键原则:provider 返回的 `knowledge_filename` / `chunk_id` 不能直接当 MNote 文件真相,必须回查 registry 映射到 `resource_id/source_uri/open_reference`。
|
||||||
|
|
||||||
|
## 6. 页面打开 / 文件打开 / 引用回跳融合
|
||||||
|
|
||||||
|
### 6.1 冲突
|
||||||
|
|
||||||
|
- MNote 自己就是 FileManager:resource tree / file tree / document pane / resource tab 是唯一页面/文件打开入口。
|
||||||
|
- 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 页面。
|
||||||
|
|
||||||
|
### 6.2 决策
|
||||||
|
|
||||||
|
所有打开动作只发生在 MNote Page AI / document pane 内:
|
||||||
|
|
||||||
|
- changed file chip 点击:`window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。
|
||||||
|
- 当前页被修改:`window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument()` 或 watcher 链路。
|
||||||
|
- 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 页面。
|
||||||
|
|
||||||
|
### 6.3 MNote Page AI open reference payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "weknora",
|
||||||
|
"providerKbId": "kb-...",
|
||||||
|
"providerKnowledgeId": "...",
|
||||||
|
"providerChunkId": "...",
|
||||||
|
"sourceId": "mnote-source-...",
|
||||||
|
"rootUri": "file:///...",
|
||||||
|
"sourcePath": "docs/a.md",
|
||||||
|
"locator": {
|
||||||
|
"kind": "markdown-range",
|
||||||
|
"heading": "...",
|
||||||
|
"lineStart": 12,
|
||||||
|
"lineEnd": 20,
|
||||||
|
"quote": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端必须二次校验:当前用户、rootUri、workspace、source ownership、allowed read scope。这个 payload 只服务 MNote Page AI 中的点击打开,不是 OpenHub FileManager 或 WeKnora 前端合同。
|
||||||
|
|
||||||
|
## 7. OpenHub FastAPI / Redis / opencode Runtime 安排
|
||||||
|
|
||||||
|
### 7.1 核心边界
|
||||||
|
|
||||||
|
这里不应让 MNote 重写 OpenHub FastAPI 的 opencode client。最小干扰路径是运行 OpenHub AI 后端栈,让 MNote 只做宿主、scope 注入、入口裁剪和回跳桥:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI host
|
||||||
|
-> MNote 校验登录态、workspace、rootUri、allowed roots
|
||||||
|
-> 注入/映射 OpenHub user workspace + session/skill/MCP scope
|
||||||
|
-> OpenHub AI UI
|
||||||
|
-> OpenHub FastAPI + Redis/session/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 当自己的消息、权限、文件或知识库真相。
|
||||||
|
- **MNote 不重写 planner/client**:MNote 不替 OpenHub 判断何时调用 WeKnora,也不重写 opencode prompt/event/diff 流程。
|
||||||
|
- **MNote 只裁剪不用入口**:OpenHub Login、FileManager、KnowledgeManager、Admin 等页面不暴露;必要时在 proxy 层 404、隐藏菜单或跳转到 MNote 对应页面。
|
||||||
|
|
||||||
|
### 7.2 FastAPI 的具体作用
|
||||||
|
|
||||||
|
OpenHub FastAPI 在本设计中保留以下作用:
|
||||||
|
|
||||||
|
- 管理 OpenHub conversation/session/message/history。
|
||||||
|
- 管理 skill、agent/MCP、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 配置。
|
||||||
|
|
||||||
|
OpenHub FastAPI 不保留以下作用:
|
||||||
|
|
||||||
|
- 不作为 MNote 登录入口。
|
||||||
|
- 不接管 MNote resource tree / FileTree / document pane。
|
||||||
|
- 不启用 OpenHub 自带 KnowledgeManager 作为知识库 UI。
|
||||||
|
- 不启用 OpenHub 自带轻量 knowledge tables 作为 RAG 底座。
|
||||||
|
- 不执行 Git snapshot / restore / revert,除非未来另开设计并经 MNote 显式确认。
|
||||||
|
|
||||||
|
### 7.3 Redis 安排
|
||||||
|
|
||||||
|
- OpenHub 需要的 Redis/cache/queue 由 OpenHub deployment 管理,随 OpenHub FastAPI 启停和 health check。
|
||||||
|
- WeKnora 如果有内部 Redis/队列依赖,由 WeKnora stack 管理,MNote 只检查 WeKnora health。
|
||||||
|
- MNote control-plane 不依赖 Redis 保存登录授权、workspace grants、source registry、open-reference 或文件真相。
|
||||||
|
- Redis 中只允许放可重建状态:缓存、队列、临时流状态;不能成为用户权限、文件内容、知识库 source registry 或 Page AI 消息的唯一持久真相。
|
||||||
|
|
||||||
|
### 7.4 Page AI context
|
||||||
|
|
||||||
|
MNote 注入给 OpenHub / opencode 的 context:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- 当前 MNote 用户/匿名显示名,不含敏感 cookie/token
|
||||||
|
- 当前 workspace/rootUri
|
||||||
|
- 当前页面标题、真实 Markdown path、resource id
|
||||||
|
- selection 摘要
|
||||||
|
- allowed roots / write constraints
|
||||||
|
- OpenHub session scope / skill scope / MCP scope / tool permission scope
|
||||||
|
- WeKnora tool scope:允许查询的 kb ids/source ids/citation policy
|
||||||
|
- MNote 文件打开/刷新 bridge usage
|
||||||
|
```
|
||||||
|
|
||||||
|
不要注入整篇正文;路径与 allowed roots 足够让 opencode 在本地读取文件。正文只在 selection 或用户明确需要时作为有限上下文传入。
|
||||||
|
|
||||||
|
## 8. UI 边界:嵌入 OpenHub AI 面板,保留 AI 原能力
|
||||||
|
|
||||||
|
### 8.1 Page AI Shell
|
||||||
|
|
||||||
|
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 等设置。
|
||||||
|
- MNote context:以 context pills / hidden bootstrap / postMessage / proxy header 方式注入当前 page path、rootUri、selection、allowed roots,不强行改 OpenHub UI 主结构。
|
||||||
|
- 回跳:changed file、citation、reference 点击时走 MNote bridge 打开页面。
|
||||||
|
|
||||||
|
### 8.2 OpenHub 前端裁剪策略
|
||||||
|
|
||||||
|
第一阶段不是“大面积禁用 OpenHub 前端”,而是**保留 AI 面板相关能力,只掐断与 MNote 真相冲突的入口**:
|
||||||
|
|
||||||
|
保留:
|
||||||
|
|
||||||
|
- AI chat 主界面。
|
||||||
|
- history / session 抽屉。
|
||||||
|
- MCP 设置与连接状态。
|
||||||
|
- skill / agent 设置。
|
||||||
|
- tool/model permission UI。
|
||||||
|
- tool card、diff、changed files、运行日志等 AI 运行态 UI。
|
||||||
|
|
||||||
|
裁剪或转接:
|
||||||
|
|
||||||
|
- OpenHub Login:禁用,改由 MNote 登录态注入 OpenHub 派生用户。
|
||||||
|
- OpenHub workspace selector:禁用或固定为 MNote 授权 rootUri 派生 workspace。
|
||||||
|
- OpenHub FileManager:不作为文件真相;若 AI 面板内出现文件入口,转接到 MNote document pane / FileTree。
|
||||||
|
- OpenHub KnowledgeManager:不作为知识库 UI;如入口存在,转接到 MNote 知识库展示层或隐藏。
|
||||||
|
- OpenHub Admin / Team / Scheduler / SmartEntity:默认隐藏或不可达,除非后续明确纳入 Page AI 管理面。
|
||||||
|
- OpenHub Git snapshot / restore:默认关闭,避免改写 MNote local-first workspace 版本语义。
|
||||||
|
|
||||||
|
### 8.3 MNote 暴露给 OpenHub 的边界能力
|
||||||
|
|
||||||
|
MNote 不替 OpenHub 判断何时调用知识库、何时用 skill/MCP、如何组织 tool call;这些交给 OpenHub/opencode 已有 MCP/skill/agent 规范处理。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
|
||||||
|
- open-reference bridge:把 provider citation/chunk/source 映射成 MNote 页面打开动作
|
||||||
|
- changed-file bridge:把 OpenHub/opencode 返回的 path 映射成 MNote document pane 打开/刷新
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说,MNote 是宿主、授权边界和回跳桥,不是 OpenHub agent 的 planner,也不是 OpenHub AI 面板的重写者。
|
||||||
|
|
||||||
|
## 9. 数据流:OpenHub 原生执行,MNote 注入边界
|
||||||
|
|
||||||
|
### 9.1 发送 Page AI 消息
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户输入
|
||||||
|
-> MNote Page AI host 中的 OpenHub AI 面板
|
||||||
|
-> OpenHub 前端调用 OpenHub FastAPI
|
||||||
|
-> OpenHub FastAPI 使用 OpenHub session/message/skill/MCP/tool permission
|
||||||
|
-> OpenHub FastAPI 调 opencode serve
|
||||||
|
-> opencode 读写 MNote 授权 rootUri 内文件
|
||||||
|
-> OpenHub session/message/tool history 持久化
|
||||||
|
-> OpenHub AI UI 渲染回复、tool card、diff、changed files
|
||||||
|
-> MNote bridge 只处理文件打开、刷新、citation 回跳
|
||||||
|
```
|
||||||
|
|
||||||
|
MNote 不在消息主链里重写 OpenHub planner,也不解析知识需求后替 OpenHub 决定调用 WeKnora。MNote 只负责登录态、workspace 授权、scope 注入和结果回跳。
|
||||||
|
|
||||||
|
### 9.2 知识检索
|
||||||
|
|
||||||
|
```text
|
||||||
|
OpenHub/opencode 判断需要知识
|
||||||
|
-> 按 MCP/skill/tool 规范调用已注册的 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 预先约束。
|
||||||
|
|
||||||
|
### 9.3 知识库生成与展示
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote 知识库展示层上传/添加资料
|
||||||
|
-> MNote canonical knowledge API
|
||||||
|
-> MNote 校验 user/workspace/rootUri/write permission
|
||||||
|
-> WeKnora file/manual/url ingest
|
||||||
|
-> 写 mnote_knowledge_sources registry
|
||||||
|
-> FileTree/Knowledge UI 显示 indexing 状态
|
||||||
|
-> 生成/更新可供 OpenHub/opencode 使用的 WeKnora MCP/skill/tool 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边界配置好的 WeKnora 工具。知识库生成页面复用 WeKnora KB 页面体验,但 source 选择应以 MNote 本地文件/文件夹集合为入口。
|
||||||
|
|
||||||
|
## 10. 可执行 Checklist
|
||||||
|
|
||||||
|
### 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。
|
||||||
|
|
||||||
|
### 10.2 OpenHub 服务栈接入
|
||||||
|
|
||||||
|
- [ ] 确认 OpenHub 本机源码路径、启动命令、依赖文件和默认端口。
|
||||||
|
- [ ] 确认 OpenHub FastAPI 是否真实依赖 Redis;记录 Redis host/port/env 和启动顺序。
|
||||||
|
- [ ] 确认 OpenHub FastAPI 调 opencode 的配置项:opencode base URL、directory 参数、BasicAuth、模型/provider env。
|
||||||
|
- [ ] 在 `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 进程。
|
||||||
|
- [ ] 明确 OpenHub Git snapshot / restore / revert 默认关闭,并在启动环境或配置中落实。
|
||||||
|
- [ ] 写 smoke:OpenHub FastAPI 可列 session,Redis 可达,opencode serve 可达。
|
||||||
|
|
||||||
|
### 10.3 MNote 登录态到 OpenHub Scope
|
||||||
|
|
||||||
|
- [ ] 定义 `openhub_user_key = stable_hash(mnote_user_id)`。
|
||||||
|
- [ ] 定义 `openhub_workspace_key = stable_hash(workspace_id, root_uri)`。
|
||||||
|
- [ ] 定义 session scope:`mnote_user_id + workspace_id + root_uri + page_resource_id`。
|
||||||
|
- [ ] 在 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。
|
||||||
|
- [ ] 写 control-plane 测试:binding 受 user/workspace/rootUri 隔离,跨用户查询失败。
|
||||||
|
|
||||||
|
### 10.4 OpenHub AI 面板嵌入
|
||||||
|
|
||||||
|
- [ ] 在 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、运行日志。
|
||||||
|
- [ ] FileManager 入口若出现在 AI 面板内,跳转 MNote document pane / FileTree 或禁用。
|
||||||
|
- [ ] KnowledgeManager 入口若出现在 AI 面板内,跳转 MNote WeKnora 知识库页或隐藏。
|
||||||
|
- [ ] MNote context 通过 postMessage、proxy header 或 bootstrap JSON 注入 page path、title、selection、rootUri、allowed roots。
|
||||||
|
- [ ] 写浏览器 smoke:Page AI 显示 OpenHub AI 面板,刷新后 session/history 仍可恢复。
|
||||||
|
- [ ] 写浏览器 smoke:访问 OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口不会接管 MNote。
|
||||||
|
|
||||||
|
### 10.5 OpenHub 原生 opencode 链路
|
||||||
|
|
||||||
|
- [ ] 保持 OpenHub FastAPI 调用 `opencode serve`,MNote 不重写 prompt/event/diff client。
|
||||||
|
- [ ] 确认 OpenHub 创建 session 时 directory 固定为 MNote 授权 rootUri。
|
||||||
|
- [ ] 确认 OpenHub 发送 prompt 后可监听 `/global/event` 并渲染 tool card / assistant message。
|
||||||
|
- [ ] 确认 OpenHub 可读取 `/session/{id}/diff` 或等价 changed files。
|
||||||
|
- [ ] MNote 只读取 changed file / diff artifact 的 path 和 session id,用于 open/refresh。
|
||||||
|
- [ ] changed file 点击调用 `window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。
|
||||||
|
- [ ] 当前打开页面被修改后走 watcher 或 `refreshPrimaryDocument()` 刷新。
|
||||||
|
- [ ] 写真实 smoke:让 OpenHub AI 修改 rootUri 内 Markdown,MNote 当前页面可看到变更。
|
||||||
|
|
||||||
|
### 10.6 WeKnora 知识库页面替换
|
||||||
|
|
||||||
|
- [ ] 盘点 WeKnora `KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue` 的依赖。
|
||||||
|
- [ ] 决定复用方式:嵌 WeKnora frontend route、抽组件、或做 MNote adapter 页面复刻 WeKnora 交互。
|
||||||
|
- [ ] 知识库定义为 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 自己的独立文件真相。
|
||||||
|
- [ ] 入库时 MNote 先校验 allowed roots,再调用 WeKnora file/manual/url ingest。
|
||||||
|
- [ ] WeKnora processing / reparse / failed / indexed 状态同步到 MNote registry 与 FileTree 灯号。
|
||||||
|
- [ ] 删除 source 时只删除知识库索引和 registry 映射,不删除本地原文件。
|
||||||
|
- [ ] 写浏览器 smoke:创建 KB、添加本地文件夹、看到 indexing 状态、完成后可检索。
|
||||||
|
|
||||||
|
### 10.7 WeKnora 检索替换 LightRAG
|
||||||
|
|
||||||
|
- [ ] 抽出 `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/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。
|
||||||
|
- [ ] WeKnora RRF score 不沿用 LightRAG 阈值;UI 只显示排序分或弱化分值解释。
|
||||||
|
- [ ] 保留旧 LightRAG provider 为 legacy fallback,但默认隐藏且不作为 smoke 基线。
|
||||||
|
- [ ] 更新现有 knowledge-rag smoke,把 provider 断言从 `lightrag` 改为 `weknora`。
|
||||||
|
- [ ] 写单元测试:WeKnora SearchResult 映射为 MNote search result / citation / open-reference。
|
||||||
|
|
||||||
|
### 10.8 WeKnora MCP/CLI Tool Bridge
|
||||||
|
|
||||||
|
- [ ] 选定第一阶段 MCP surface:Go CLI `weknora mcp serve` 或 Python `mcp-server`,记录读写能力差异。
|
||||||
|
- [ ] 默认暴露只读工具:`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。
|
||||||
|
- [ ] tool result 返回 provider ids、quote、chunk metadata、MNote open-reference token。
|
||||||
|
- [ ] citation 点击由 MNote bridge 打开,不由 OpenHub 或 WeKnora 决定本地路径。
|
||||||
|
- [ ] 写真实 smoke:OpenHub AI 通过 MCP/CLI tool 查询 WeKnora,回答中出现可回跳 citation。
|
||||||
|
|
||||||
|
### 10.9 dev-hot 与真实验收
|
||||||
|
|
||||||
|
- [ ] `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 入口仍可用。
|
||||||
|
- [ ] OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口被隐藏、404 或跳转 MNote。
|
||||||
|
- [ ] 发送消息后 OpenHub session/message/history 持久化,刷新浏览器可恢复。
|
||||||
|
- [ ] 让 AI 修改当前 rootUri 内 Markdown,MNote document pane 可刷新并显示变更。
|
||||||
|
- [ ] 在 WeKnora 知识库页用本地文件/文件夹集合建库,完成 ingest/index。
|
||||||
|
- [ ] OpenHub AI 通过 WeKnora MCP/CLI/API tool 检索该 KB,并返回 citation。
|
||||||
|
- [ ] 点击 citation 打开 MNote 对应页面或资源 tab。
|
||||||
|
- [ ] 保存 smoke 输出和关键截图;失败时标明 OpenHub FastAPI、Redis、opencode、WeKnora、MNote bridge 中哪一层失败。
|
||||||
|
|
||||||
|
## 11. 取舍矩阵
|
||||||
|
|
||||||
|
| 冲突点 | 直接用 OpenHub | 直接用 WeKnora | MNote 边界方案 | 决策 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 登录 | 第二套 JWT/localStorage | 第二套 tenant login/API key | MNote cookie -> scope/binding | 选 MNote 登录入口 + OpenHub 派生 scope |
|
||||||
|
| 消息历史 | OpenHub conversation/session | WeKnora chat session 暂弃用 | MNote binding + artifact index | 选 OpenHub session 为真相,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 知识工具 | 组合 |
|
||||||
|
| 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 边界控制 |
|
||||||
|
|
||||||
|
## 12. 风险与防线
|
||||||
|
|
||||||
|
### 12.1 风险:三套权限漂移
|
||||||
|
|
||||||
|
防线:MNote 是浏览器入口和授权边界;OpenHub/opencode 只能拿到 MNote 派生 scope、allowed roots、tool/MCP 配置,不直接获得未过滤 workspace/rootUri。
|
||||||
|
|
||||||
|
补充:WeKnora RBAC 只能作为 provider 防线,不作为 MNote 授权来源;RBAC 关闭、API key 复用或共享空间变化时,MNote 过滤结果仍必须保持一致。
|
||||||
|
|
||||||
|
### 12.2 风险:WeKnora citation 无法定位到本地文件
|
||||||
|
|
||||||
|
防线:ingest 时必须写 `source_uri/source_hash/provider_knowledge_id`;检索返回后以 provider id 回查 registry,失败时 UI 标记“定位降级”,不能伪造路径。
|
||||||
|
|
||||||
|
### 12.3 风险:OpenHub UI 迁移成本变成 fork
|
||||||
|
|
||||||
|
防线:第一阶段嵌入 OpenHub AI 面板,尽量保持 OpenHub 已有 AI 功能不动;只对非 AI 入口通过隐藏、404、反代拦截或跳转 MNote 做最小裁剪。后期如需精简或替换组件,再单独做 UI 迁移设计。
|
||||||
|
|
||||||
|
### 12.4 风险:opencode 工作目录过大导致找不到文件
|
||||||
|
|
||||||
|
防线:工作目录固定为当前打开 rootUri;context 中传相对 path、allowed roots、当前页面 path;切换 rootUri 新开 session;不把大仓根或 recycle 目录作为默认工作目录。
|
||||||
|
|
||||||
|
### 12.5 风险:旧 LightRAG 残留误导
|
||||||
|
|
||||||
|
防线:UI 文案和配置改 provider-neutral;默认 provider 改 WeKnora;旧 LightRAG tools/manifest 标记 legacy 或隐藏。
|
||||||
|
|
||||||
|
### 12.6 风险:OpenHub 自动 Git snapshot 污染 workspace
|
||||||
|
|
||||||
|
防线:只参考 OpenHub AI 页面与 event/diff 解析思路;不迁移 Git snapshot / restore 后端路径。若以后需要版本恢复,必须走 MNote DocumentBuffer / watcher / 显式用户确认的版本策略。
|
||||||
|
|
||||||
|
### 12.7 风险:MCP/CLI 写权限边界不清
|
||||||
|
|
||||||
|
防线:WeKnora 有 Go CLI `weknora mcp serve` 与 Python `mcp-server` 两套 surface,读写能力不同。MNote 接入前必须指定采用哪一套、默认只读还是允许 create/delete,并把写操作纳入 MNote 权限审批。
|
||||||
|
|
||||||
|
## 13. 验收标准
|
||||||
|
|
||||||
|
MVP 通过必须同时满足:
|
||||||
|
|
||||||
|
- 浏览器中 Page AI 嵌入 OpenHub AI 面板,并能显示 OpenHub 原生消息/tool/diff/history。
|
||||||
|
- MNote 登录态是唯一登录入口;无 OpenHub/WeKnora 登录跳转。
|
||||||
|
- 同一 MNote 用户跨浏览器可恢复绑定到当前页面/rootUri 的 OpenHub session。
|
||||||
|
- opencode 在当前 rootUri 下能真实读取/修改 Markdown。
|
||||||
|
- changed file chips 与 DiffViewer path 能用 MNote 打开。
|
||||||
|
- WeKnora search/MCP/CLI 工具返回真实引用,citation 经 registry 映射后可回跳 MNote 文件;失败时明确定位降级,且不能把 `knowledge_filename` 伪装成本地路径。
|
||||||
|
- `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`
|
||||||
|
- 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`
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# 7-62 [recycle] Page AI Board-first full rewrite v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-19
|
||||||
|
>
|
||||||
|
> 当前状态:`RECYCLE`
|
||||||
|
>
|
||||||
|
> Owner:07-ai / Agent Board bridge / Page AI shell / MNote capability plugins
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `ARCHITECTURE.md`
|
||||||
|
> - `CURRENT_ARCHITECTURE.md`
|
||||||
|
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
|
||||||
|
> - `design/07-ai/done/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
|
||||||
|
> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md`
|
||||||
|
> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md`
|
||||||
|
> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
|
||||||
|
> - `design/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md`
|
||||||
|
|
||||||
|
> 过时原因:已由 `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md` 替代;Page AI 新主路径只接入 opencode 官方 runtime + 官方 WebUI。
|
||||||
|
|
||||||
|
## 1. 第一结论
|
||||||
|
|
||||||
|
当前 Page AI 继续维护 Hermes / Reasonix / Chat-only / ACP runtime / provider session / skill toggle / tool event 映射,会让 MNote 重复承担 Agent Board 已经承担的中介层职责。后续不应再“小修小补”当前 Page AI,而应完整重构为 **Board-first Page AI**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI = 页面入口 + 上下文/权限/目标封装 + 运行态展示
|
||||||
|
Agent Board = worker 选择 + provider 适配 + workflow 编排 + QA/review/失败回流
|
||||||
|
ZCode/Reasonix/Codex/GenericAgent = Board worker,不直接成为 MNote Page AI provider
|
||||||
|
```
|
||||||
|
|
||||||
|
重构目标不是“把 ZCode 接进 Page AI”,而是“让 Page AI 不再适配 provider”。ZCode 胜出只决定 Board 默认 worker,MNote 只调用 Board。
|
||||||
|
|
||||||
|
## 2. 当前问题
|
||||||
|
|
||||||
|
### 2.1 Page AI 责任过载
|
||||||
|
|
||||||
|
当前 Page AI 同时负责:
|
||||||
|
|
||||||
|
- agent/provider selector:Hermes / Reasonix / Chat-only。
|
||||||
|
- ACP runtime lifecycle:session、resume、queue、event、permission。
|
||||||
|
- provider 特化逻辑:Reasonix native-live、Hermes profile、Chat-only remote conversation。
|
||||||
|
- skills/tools UI:MNote skills、Hermes skills、Reasonix skills 混合展示。
|
||||||
|
- run history:MNote 本地 session、外部 provider conversation、runtime audit 混合展示。
|
||||||
|
- 写入主路径:既有 MNote tool 写入,又有 agent 原生文件编辑。
|
||||||
|
|
||||||
|
这导致每新增一个 agent 或 provider 都要重复改 Page AI 前端、Rust route、session store、event parser、设置页和 smoke。
|
||||||
|
|
||||||
|
### 2.2 与 Agent Board 职责重复
|
||||||
|
|
||||||
|
Agent Board 已经具备:
|
||||||
|
|
||||||
|
- worker preset 与角色路由。
|
||||||
|
- ZCode / Reasonix / Codex / GenericAgent / reviewer / QA browser worker。
|
||||||
|
- workflow run、task group、review、QA、git gate。
|
||||||
|
- provider event 映射、任务状态、summary、MemPalace 归档。
|
||||||
|
- ZCode app-server / CLI fallback 链路。
|
||||||
|
|
||||||
|
MNote 再维护一套 provider 层会形成双中介,长期 bug 面大于收益。
|
||||||
|
|
||||||
|
## 3. 新系统边界
|
||||||
|
|
||||||
|
### 3.1 MNote Page AI 只保留四件事
|
||||||
|
|
||||||
|
1. **上下文采集**:当前页、选区、打开资源、页面标题、rootUri、workspaceId、sourceKind、Page Aggregate snapshot。
|
||||||
|
2. **权限与目标**:allowed roots、write/read policy、当前目标文件、是否允许修改真实文件。
|
||||||
|
3. **任务入口**:选择 worker / workflow / intent,提交 Board run。
|
||||||
|
4. **可视化回放**:展示 Board run/task/group 状态、事件、summary、文件变更提示、QA 截图链接。
|
||||||
|
|
||||||
|
### 3.2 Agent Board 承担中介层
|
||||||
|
|
||||||
|
Board 负责:
|
||||||
|
|
||||||
|
- provider 适配:ZCode / Reasonix / Codex / future agents。
|
||||||
|
- worker 选择:developer / reviewer / qa / designer / researcher / workflow。
|
||||||
|
- workflow:快速编辑、问答、bug 修复、功能开发、QA 验证、review gate。
|
||||||
|
- run 状态:running / blocked / failed / complete。
|
||||||
|
- 事件:file_read / file_edit / command / command_output / screenshot / summary。
|
||||||
|
- 失败回流与人工介入。
|
||||||
|
|
||||||
|
### 3.3 MNote skills / plugins 重新定义
|
||||||
|
|
||||||
|
MNote 的 skill/plugin 不再是 “Hermes/Reasonix profile 内的技能开关”,而是 Board 可消费的 **MNote capability manifest**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mnote.current_page.read
|
||||||
|
mnote.selection.read
|
||||||
|
mnote.open_resources.snapshot
|
||||||
|
mnote.allowed_roots.describe
|
||||||
|
mnote.lightrag.query
|
||||||
|
mnote.reference.open
|
||||||
|
mnote.page_aggregate.snapshot
|
||||||
|
mnote.local_file.receipt
|
||||||
|
```
|
||||||
|
|
||||||
|
这些 capability 由 MNote 生成 manifest/envelope,Board worker 通过任务 prompt、MCP 或后续 bridge tool 使用。MNote 不再为每个 worker 维护单独 skill UI。
|
||||||
|
|
||||||
|
## 4. 新 Page AI 信息架构
|
||||||
|
|
||||||
|
### 4.1 顶层只保留四个页签
|
||||||
|
|
||||||
|
| 页签 | 作用 | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `对话/任务` | 用户输入、当前目标、提交 Board run | MNote |
|
||||||
|
| `工作流` | worker / workflow 选择、执行策略 | Agent Board |
|
||||||
|
| `运行` | Board run/task/group 状态、事件、summary | Agent Board |
|
||||||
|
| `上下文` | 当前页、选区、资源、权限、LightRAG capability | MNote |
|
||||||
|
|
||||||
|
删除旧一级页签:
|
||||||
|
|
||||||
|
- `Hermes`
|
||||||
|
- `Reasonix`
|
||||||
|
- `Chat-only`
|
||||||
|
- `Runtime`
|
||||||
|
- `高级`
|
||||||
|
- provider-specific skills 面板
|
||||||
|
|
||||||
|
如确需保留,只能放在 debug/legacy 折叠区,不作为默认主链。
|
||||||
|
|
||||||
|
### 4.2 默认入口
|
||||||
|
|
||||||
|
默认按钮不再是“发送给 Reasonix/Hermes”,而是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
交给 Agent Board
|
||||||
|
```
|
||||||
|
|
||||||
|
默认配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
projectId = mnote 项目
|
||||||
|
workflow = builtin-page-ai-developer-direct
|
||||||
|
worker = Board direct workflow developer(现为 zcode-deepseek-flash)
|
||||||
|
writePolicy = 按 MNote allowed roots
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 接口契约
|
||||||
|
|
||||||
|
### 5.1 MNote → Board envelope
|
||||||
|
|
||||||
|
新增 `MNoteBoardTaskEnvelope`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "mnote.page_ai.board_task.v1",
|
||||||
|
"intent": "ask|edit|fix|feature|qa|review|custom_workflow",
|
||||||
|
"message": "用户原始输入",
|
||||||
|
"workspaceId": "...",
|
||||||
|
"sourceKind": "local-folder",
|
||||||
|
"rootUri": "file:///...",
|
||||||
|
"documentId": "...",
|
||||||
|
"pageTitle": "...",
|
||||||
|
"primaryTarget": {
|
||||||
|
"kind": "markdown_file",
|
||||||
|
"relativePath": "docs/page.md",
|
||||||
|
"absolutePath": "/.../docs/page.md"
|
||||||
|
},
|
||||||
|
"selection": {
|
||||||
|
"text": "...",
|
||||||
|
"range": null
|
||||||
|
},
|
||||||
|
"contextRefs": ["current_page", "selection", "active_editor", "folder"],
|
||||||
|
"allowedRoots": [
|
||||||
|
{ "rootUri": "file:///...", "permission": "write" }
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"mnote.current_page.read",
|
||||||
|
"mnote.allowed_roots.describe",
|
||||||
|
"mnote.lightrag.query"
|
||||||
|
],
|
||||||
|
"writePolicy": {
|
||||||
|
"allowFileWrite": true,
|
||||||
|
"requireHumanApproval": false,
|
||||||
|
"forbidMnoteInternalStateWrite": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Board API 选择
|
||||||
|
|
||||||
|
MNote 服务端先封装 Board API,不让前端直连 `3901`。
|
||||||
|
|
||||||
|
新增 MNote routes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/page-ai/board/status
|
||||||
|
GET /api/page-ai/board/workers
|
||||||
|
GET /api/page-ai/board/workflows
|
||||||
|
POST /api/page-ai/board/runs
|
||||||
|
GET /api/page-ai/board/runs/:id
|
||||||
|
POST /api/page-ai/board/runs/:id/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
内部对应 Board:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET http://127.0.0.1:3901/api/health
|
||||||
|
GET http://127.0.0.1:3901/api/workers/catalog?projectId=<mnote>
|
||||||
|
GET http://127.0.0.1:3901/api/workflow-presets?projectId=<mnote>
|
||||||
|
POST http://127.0.0.1:3901/api/workflow-runs
|
||||||
|
GET http://127.0.0.1:3901/api/workflow-runs/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
优先使用 `/api/workflow-runs`,而不是 `/api/controller/message`,因为 Page AI 需要可预测的 workflow/worker 选择和 run id。
|
||||||
|
|
||||||
|
### 5.4 Board 需要补给 MNote 的接口
|
||||||
|
|
||||||
|
这不是单向的“ MNote 适配 Board ”,而是双向协议。为了让 Page AI 真正瘦身,Board 侧还需要补出一组面向 MNote 的稳定接口:
|
||||||
|
|
||||||
|
#### A. Page AI envelope 预检
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/page-ai/envelopes/validate
|
||||||
|
```
|
||||||
|
|
||||||
|
作用:在真正创建 run 前,校验 envelope 是否满足 workflow/worker 的最小要求,返回:
|
||||||
|
|
||||||
|
- 缺失的字段。
|
||||||
|
- 是否需要 worker 具备 vision / browser / filesystem / multimodal。
|
||||||
|
- 是否需要人工确认。
|
||||||
|
- 推荐 workflowId / workerPresetId。
|
||||||
|
|
||||||
|
这样 MNote 可以在 UI 层先提示“当前上下文不够”或“建议切换到 QA workflow”,避免无效提交。
|
||||||
|
|
||||||
|
#### B. Page AI envelope 到 workflow 的映射
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/page-ai/envelopes/route
|
||||||
|
```
|
||||||
|
|
||||||
|
作用:Board 根据 envelope 自动选择 workflow / worker / reviewer / QA 路线,返回最终路由结果:
|
||||||
|
|
||||||
|
- workflowId。
|
||||||
|
- workerPresetId。
|
||||||
|
- groupId 或 runId。
|
||||||
|
- 是否需要用户二次确认。
|
||||||
|
- 预估阶段序列。
|
||||||
|
|
||||||
|
这能让 MNote 不再内置 worker 选择逻辑,只保留“意图 + 上下文”。
|
||||||
|
|
||||||
|
#### C. Run 级事件流
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/workflow-runs/:id/events
|
||||||
|
GET /api/workflow-runs/:id/node-runs/:nodeRunId/events
|
||||||
|
```
|
||||||
|
|
||||||
|
作用:MNote 不只看最终 run summary,而是直接消费 Board 的结构化事件流,渲染:
|
||||||
|
|
||||||
|
- 当前节点。
|
||||||
|
- 进度阶段。
|
||||||
|
- file_read / file_edit / command / screenshot / artifact。
|
||||||
|
- block / retry / resume 原因。
|
||||||
|
|
||||||
|
如果 Board 未来提供 WS/SSE,这两条可退化为 snapshot/polling 兼容层,但 MNote 默认应面向事件流抽象,而不是 provider 私有日志。
|
||||||
|
|
||||||
|
#### D. Run receipt / artifact receipt
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/workflow-runs/:id/receipt
|
||||||
|
```
|
||||||
|
|
||||||
|
作用:返回统一的运行收据,便于 MNote 做历史展示和刷新同步:
|
||||||
|
|
||||||
|
- `runId` / `projectId` / `workflowId`。
|
||||||
|
- `workerPresetId` / `workerName`。
|
||||||
|
- `summary` / `status` / `completedAt`。
|
||||||
|
- `artifactRefs`。
|
||||||
|
- `targetSnapshot` / `allowedRoots` / `capabilities`。
|
||||||
|
|
||||||
|
这会让 Page AI history 直接依赖 Board receipt,而不是再保留一套 Hermes session 语义。
|
||||||
|
|
||||||
|
#### E. Board 侧能力目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/workers/catalog?projectId=<mnote>
|
||||||
|
GET /api/workflow-presets?projectId=<mnote>
|
||||||
|
GET /api/projects/:id/capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
作用:让 MNote 能展示“Board 当前能做什么”,而不是自己猜 worker。尤其是:
|
||||||
|
|
||||||
|
- 默认 developer 是谁。
|
||||||
|
- 哪些 worker 具备 vision/browser。
|
||||||
|
- 哪些 workflow 可用于编辑、QA、review、research。
|
||||||
|
|
||||||
|
#### F. Board 侧回写钩子
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/workflow-runs/:id/report
|
||||||
|
POST /api/workflow-runs/:id/artifacts
|
||||||
|
POST /api/workflow-runs/:id/feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
作用:让 MNote 能把 UI 侧额外信息回写给 Board:
|
||||||
|
|
||||||
|
- 浏览器截图。
|
||||||
|
- 用户补充说明。
|
||||||
|
- 失败后的重新定界信息。
|
||||||
|
- 页面刷新后的 targetSnapshot 变化。
|
||||||
|
|
||||||
|
这样 Board 的 workflow 才能逐步成为真正的 Page AI 控制面,而不是一次性任务提交器。
|
||||||
|
|
||||||
|
### 5.5 Board → MNote 展示模型
|
||||||
|
|
||||||
|
MNote 只归一化 Board 状态,不解析 provider 私有事件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Board run -> Page AI run card
|
||||||
|
Workflow node -> stage timeline
|
||||||
|
Task event -> visible event row
|
||||||
|
Task summary -> assistant final message
|
||||||
|
Artifact refs -> screenshot/link/file changed chips
|
||||||
|
```
|
||||||
|
|
||||||
|
MNote 不再关心事件来自 ZCode、Reasonix 还是 Codex。
|
||||||
|
|
||||||
|
## 6. 删除与迁移边界
|
||||||
|
|
||||||
|
### 6.1 默认主链删除
|
||||||
|
|
||||||
|
以下能力退出默认主链:
|
||||||
|
|
||||||
|
- Page AI 前端 `pageAiAcpRuntime` 作为主选择器。
|
||||||
|
- Reasonix quick controls 作为默认控件。
|
||||||
|
- Hermes profile select 作为默认控件。
|
||||||
|
- Chat-only provider conversation 作为默认路径。
|
||||||
|
- MNote 内部 provider-specific run resume。
|
||||||
|
- provider-specific skill source 切换。
|
||||||
|
|
||||||
|
### 6.2 Legacy 保留原则
|
||||||
|
|
||||||
|
旧链路只允许:
|
||||||
|
|
||||||
|
- debug/internal route。
|
||||||
|
- 历史 session 读取/导出。
|
||||||
|
- 明确 fallback:Board 不可用时提示用户,而不是自动静默切回 Reasonix。
|
||||||
|
|
||||||
|
不允许:
|
||||||
|
|
||||||
|
- 新功能继续写进 `sidebar-page-ai-runtime.js` 的 provider 分支。
|
||||||
|
- 新增 Hermes/Reasonix 专属 UI 作为默认交互。
|
||||||
|
- 新增 MNote 自己的 provider adapter。
|
||||||
|
|
||||||
|
## 7. 实施计划
|
||||||
|
|
||||||
|
### Phase A:Board bridge 与新 shell 并行上线(已完成)
|
||||||
|
|
||||||
|
- [x] 新增 `page_ai_board.rs`,封装 Board status/workers/workflows/runs。
|
||||||
|
- [x] 前端默认路径已切为 Board-first run 提交和状态展示;当前以 `sidebar-page-ai-runtime.js` 内 Board-first 分支承接,后续可继续拆到独立 runtime 文件。
|
||||||
|
- [x] Board 侧补齐 `envelopes/validate`、`envelopes/route`、`runs/:id/events`、`runs/:id/receipt`、`projects/:id/capabilities` 稳定接口。
|
||||||
|
- [x] 当前 Page AI 默认入口切到 Board shell。
|
||||||
|
- [x] 旧 provider 入口默认隐藏,仅通过 legacy/debug 边界保留。
|
||||||
|
- [x] smoke:从页面提交任务,Board 创建 workflow run,Page AI 能显示 run id、complete、summary。
|
||||||
|
|
||||||
|
### Phase B:worker / workflow 选择(已完成默认链路)
|
||||||
|
|
||||||
|
- [x] MNote bridge 提供 Board workers/workflows catalog API,默认 UI 先收口为 Board direct workflow。
|
||||||
|
- [x] 默认选中 Board direct workflow developer:`zcode-deepseek-flash`。
|
||||||
|
- [ ] 后续增强:在工作流页开放更多 built-in workflow 选择。
|
||||||
|
- [x] smoke:ZCode developer 执行最小文件编辑任务,MNote 页面通过 watcher 刷新。
|
||||||
|
|
||||||
|
### Phase C:MNote capability manifest(已完成默认 envelope)
|
||||||
|
|
||||||
|
- [x] 定义并随 `mnote.page_ai.board_task.v1` envelope 下发 MNote capabilities。
|
||||||
|
- [x] 把当前页/选区/allowed roots/LightRAG/source registry 统一注入 Board input。
|
||||||
|
- [x] 默认路径收口为 capability manifest;旧 provider skill 面板隐藏为 legacy/debug。
|
||||||
|
- [x] smoke:Board worker 能看到 primary target 与 allowed roots,并只在授权目录内改文件。
|
||||||
|
|
||||||
|
### Phase D:事件与 artifact 回放(后续增强)
|
||||||
|
|
||||||
|
- [ ] Page AI 展示 Board node timeline。
|
||||||
|
- [ ] 展示 task events:file read/edit、command、command output、QA screenshot。
|
||||||
|
- [ ] 支持打开 Board project/run/task 链接。
|
||||||
|
- [ ] smoke:功能开发 workflow 完成后,Page AI 能看到 QA 截图 artifact 和 final summary。
|
||||||
|
|
||||||
|
### Phase E:旧链路下线(默认主链已下线,代码删除后续)
|
||||||
|
|
||||||
|
- [x] 默认 UI 隐藏 Hermes/Reasonix/Chat-only provider 入口,Board-first shell 成为默认主链。
|
||||||
|
- [ ] 后续清理:`hermes_client.rs` 中 Page AI provider-specific run 创建逻辑迁入 legacy/debug 边界。
|
||||||
|
- [ ] 后续清理:更新旧设计归档默认主链说明为 legacy。
|
||||||
|
- [x] smoke:默认 Page AI Board-first 代码路径不再依赖 Reasonix/Hermes provider 即可运行。
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
### 8.1 功能验收
|
||||||
|
|
||||||
|
- Page AI 默认提交创建 Board workflow run。
|
||||||
|
- 默认 worker 来自 Board,而不是 MNote 写死 Reasonix/Hermes。
|
||||||
|
- ZCode 作为 Board 默认 developer 时,Page AI 不需要任何 ZCode 专属代码即可使用。
|
||||||
|
- Board 不可用时,Page AI 明确显示“Agent Board 不可用”,不伪装为 provider 失败。
|
||||||
|
- 文件写入通过真实文件编辑完成,MNote watcher/Page Aggregate 刷新页面。
|
||||||
|
|
||||||
|
### 8.2 维护性验收
|
||||||
|
|
||||||
|
- 新 provider 只需要 Agent Board 适配,不改 MNote Page AI provider 分支。
|
||||||
|
- Page AI 前端默认路径不再包含 `reasonix/hermes/chat_only` 三套选择逻辑。
|
||||||
|
- MNote skills/plugins 只表达能力,不表达 provider。
|
||||||
|
- Rust route 中 Board bridge 与 Hermes legacy route 分离。
|
||||||
|
|
||||||
|
### 8.3 浏览器 smoke
|
||||||
|
|
||||||
|
- 登录 `http://localhost:3000/auth` 测试账号。
|
||||||
|
- 打开 local-folder Markdown 页面。
|
||||||
|
- 打开 Page AI。
|
||||||
|
- 选择默认 Board workflow。
|
||||||
|
- 提交“总结当前页并提出一个最小改进建议”。
|
||||||
|
- 页面显示 Board run id、worker、阶段、最终 summary。
|
||||||
|
- 再提交“在当前文件末尾追加一行测试内容”,确认真实 `.md` 文件变化,页面刷新可见。
|
||||||
|
|
||||||
|
## 9. 风险与处理
|
||||||
|
|
||||||
|
| 风险 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| Board 服务不可用 | Page AI 显示 Board health 错误与启动提示,不自动回退旧 provider |
|
||||||
|
| Board workflow 太重 | 默认使用 minimal workflow,完整 QA workflow 由用户选择 |
|
||||||
|
| Page AI 历史 session 断裂 | 旧 session 只读保留,新增 run 以 Board run 为历史主键 |
|
||||||
|
| Board 改文件后页面不同步 | 依赖现有 watcher/Page Aggregate;缺口作为 05-editor-mainline bug 处理 |
|
||||||
|
| MNote capability 被 worker 忽略 | Board prompt/template 中明确 envelope schema 与 allowed roots,后续再补 MCP bridge |
|
||||||
|
|
||||||
|
## 10. 立即决策
|
||||||
|
|
||||||
|
本稿确认后,后续实现不再以“接入 ZCode provider”为任务拆分,而以“Page AI Board-first full rewrite”为主线拆分。第一批代码应直接新增 Board bridge 和新 Page AI shell,而不是继续在旧 `sidebar-page-ai-runtime.js` 中叠加 provider 分支。
|
||||||
|
|
||||||
|
|
||||||
|
## 11. 完成记录(2026-06-19)
|
||||||
|
|
||||||
|
- MNote 新增 `/api/page-ai/board/*` bridge,浏览器不直连 Board `3901`。
|
||||||
|
- Agent Board 新增 Page AI envelope validate/route/events/receipt/capabilities 接口,并新增 `builtin-page-ai-developer-direct` workflow。
|
||||||
|
- Page AI 默认发送 `mnote.page_ai.board_task.v1` envelope,默认 workflow 为 `builtin-page-ai-developer-direct`,worker 为 ZCode developer。
|
||||||
|
- MNote capability 默认随 envelope 下发:`mnote.current_page.read`、`mnote.selection.read`、`mnote.open_resources.snapshot`、`mnote.allowed_roots.describe`、`mnote.lightrag.query`、`mnote.reference.open`、`mnote.page_aggregate.snapshot`、`mnote.local_file.receipt`。
|
||||||
|
- 浏览器 smoke `scripts/task762-page-ai-board-first-smoke.js` 已验证:Page AI 可创建 Board run、可见 shell 显示 Agent Board、capability 已加载、ZCode 真实编辑当前 Markdown 文件、磁盘与编辑器可见内容同步。
|
||||||
|
|
||||||
|
验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust && cargo check -p mnote-web
|
||||||
|
cd /mnt/Data1T/ai-agent-board && npm run build:server
|
||||||
|
cd /mnt/Data1T/mnote && node scripts/task762-page-ai-board-first-smoke.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# 7-63 [recycle] Page AI Board-first product shell v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-20
|
||||||
|
>
|
||||||
|
> 当前状态:`RECYCLE`
|
||||||
|
>
|
||||||
|
> Owner:07-ai / Page AI product shell / Agent Board MNote runtime / session history
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `design/07-ai/done/7-62-page-ai-board-first-full-rewrite-v1.md`
|
||||||
|
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
|
||||||
|
> - `design/07-ai/done/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
|
||||||
|
> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md`
|
||||||
|
> - `design/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md`
|
||||||
|
|
||||||
|
> 过时原因:已由 `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md` 替代;Page AI 新主路径只接入 opencode 官方 runtime + 官方 WebUI。
|
||||||
|
|
||||||
|
## 1. 当前结论
|
||||||
|
|
||||||
|
7-62 已经证明 Board-first 主链能跑:MNote Page AI 可以通过 Agent Board 创建 run,可以把 MNote capability/envelope 下发给 worker,`mnote-page-ai-zcode` 能回复,也能真实编辑当前 Markdown 文件并触发编辑器刷新。
|
||||||
|
|
||||||
|
但这还不是合格的页面 AI。当前问题不是“再给用户更多 Board 选择”,而是 **MNote 页面 AI 需要自己的产品壳**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI Product Shell
|
||||||
|
= 聊天体验 + 当前页上下文 + MNote 白名单 worker/workflow + worker 内模型选择 + session/history + run detail
|
||||||
|
|
||||||
|
Agent Board MNote Runtime
|
||||||
|
= MNote 专用 worker + MNote 专用 workflow + provider/model 执行 + receipt/event 持久化
|
||||||
|
```
|
||||||
|
|
||||||
|
Page AI 不是 Agent Board 控制台的缩小版。默认 UI 不应该暴露 Board 全量 worker、全量 workflow、provider adapter 或通用任务报告。用户只需要面对少量 MNote 语义清楚的选择。
|
||||||
|
|
||||||
|
## 2. 要解决的四个问题
|
||||||
|
|
||||||
|
### 2.1 Worker 怎么选
|
||||||
|
|
||||||
|
Page AI 只能选择 MNote Page AI 白名单 worker。当前默认只有:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workerPresetId = mnote-page-ai-zcode
|
||||||
|
name = MNote 页面 AI · ZCode
|
||||||
|
surface = mnote-page-ai
|
||||||
|
capabilities = text, repo-edit, terminal, mnote-capability-envelope, local-markdown-edit
|
||||||
|
```
|
||||||
|
|
||||||
|
不暴露 Board 全量 worker catalog。Reasonix、Codex、ZCode、QA worker 都是 Board 内部 worker 类型,不直接成为 MNote 页面用户的选择项。后续如果需要新增 worker,必须在代码中显式登记为 `surface=mnote-page-ai`,同时补齐:
|
||||||
|
|
||||||
|
- 用户可见名称。
|
||||||
|
- 适合场景。
|
||||||
|
- 允许模型档位。
|
||||||
|
- 权限能力。
|
||||||
|
- smoke 验收。
|
||||||
|
|
||||||
|
当白名单只有一个 worker 时,UI 不显示“可切换 worker”的伪入口,只显示当前 worker 和模型档位。
|
||||||
|
|
||||||
|
### 2.2 模型怎么改
|
||||||
|
|
||||||
|
模型选择挂在特定 MNote worker 下,不做 provider picker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workerPresetId = mnote-page-ai-zcode
|
||||||
|
modelOverride = zcode-default | zcode-fast | zcode-strong
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 展示为 worker chip 旁的 model chip,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote 页面 AI · ZCode / 默认
|
||||||
|
```
|
||||||
|
|
||||||
|
用户可以切换模型档位,但不能直接选择 provider。MNote 不重新维护 Hermes/Reasonix/Codex/ZCode provider adapter;provider 和 credential 仍归 Board。
|
||||||
|
|
||||||
|
### 2.3 Workflow 怎么选
|
||||||
|
|
||||||
|
Page AI 只能选择 MNote Page AI 白名单 workflow。当前默认只有:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workflowId = builtin-mnote-page-ai-chat
|
||||||
|
name = MNote 页面 AI
|
||||||
|
surface = mnote-page-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
该 workflow 覆盖默认聊天、当前页轻编辑、当前页总结、当前页问答。通用 Board workflow 不出现在 Page AI picker 中。
|
||||||
|
|
||||||
|
后续确实需要时,再通过代码加入特定 MNote workflow:
|
||||||
|
|
||||||
|
| Workflow | 加入条件 | 默认 UI |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `builtin-mnote-page-ai-chat` | 已有默认主链 | 默认启用 |
|
||||||
|
| `builtin-mnote-page-ai-edit` | 需要更明确的编辑阶段和 diff/receipt | 加入白名单后显示 |
|
||||||
|
| `builtin-mnote-page-ai-review` | 需要当前页审阅、总结、检查 | 加入白名单后显示 |
|
||||||
|
|
||||||
|
复杂开发、hotfix、QA/browser workflow 不作为默认页面 AI 能力。只有当它们被改造成 MNote 专用 workflow,并明确加入白名单后,才允许显示。
|
||||||
|
|
||||||
|
### 2.4 Session 怎么选、怎么看历史
|
||||||
|
|
||||||
|
Page AI session 迁移为 Board-first session。每条 assistant 消息必须知道它来自哪个 Board run,但主聊天只显示用户需要的回答。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "mnote.page_ai.session.v2",
|
||||||
|
"sessionId": "...",
|
||||||
|
"workspaceId": "...",
|
||||||
|
"documentId": "...",
|
||||||
|
"rootUri": "file:///...",
|
||||||
|
"messages": [
|
||||||
|
{ "role": "user", "content": "hi,收到请回复收到。" },
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "收到。",
|
||||||
|
"boardRunId": "...",
|
||||||
|
"workflowId": "builtin-mnote-page-ai-chat",
|
||||||
|
"workerPresetId": "mnote-page-ai-zcode",
|
||||||
|
"modelOverride": "zcode-default",
|
||||||
|
"receiptId": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
历史 UI 分三层:
|
||||||
|
|
||||||
|
1. 会话列表:标题、最后回答摘要、状态、时间。
|
||||||
|
2. 会话详情:聊天消息、当前页上下文、关联 run。
|
||||||
|
3. Run detail:worker、model、workflow、timeline、changed files、receipt、失败原因。
|
||||||
|
|
||||||
|
短期可继续 localStorage/sessionStorage;产品化阶段必须落到 SQLite control-plane,避免刷新、跨标签和权限变化导致历史丢失。
|
||||||
|
|
||||||
|
## 3. 源头输出合同
|
||||||
|
|
||||||
|
MNote 专用 worker/workflow 必须从源头产出页面 AI 风格回答,而不是先产出 Board 任务报告再让 MNote 截断。
|
||||||
|
|
||||||
|
### 3.1 用户回答
|
||||||
|
|
||||||
|
简单 ask 的 worker 最终回答应直接是:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
收到。
|
||||||
|
```
|
||||||
|
|
||||||
|
当前页编辑任务的最终回答应类似:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
已把当前页面中的“A”替换为“B”。
|
||||||
|
```
|
||||||
|
|
||||||
|
不允许把以下内容作为主回答:
|
||||||
|
|
||||||
|
- `Completed`
|
||||||
|
- `Comments`
|
||||||
|
- `Remaining`
|
||||||
|
- `Agent Board run 已创建`
|
||||||
|
- 命令日志
|
||||||
|
- 工具调用过程
|
||||||
|
- provider 内部推理或计划
|
||||||
|
|
||||||
|
### 3.2 运行记录
|
||||||
|
|
||||||
|
Board runtime/workflow 负责旁路写入结构化记录:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "agent_board.workflow_run_receipt.v2",
|
||||||
|
"runId": "...",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"workflowId": "builtin-mnote-page-ai-chat",
|
||||||
|
"workerPresetId": "mnote-page-ai-zcode",
|
||||||
|
"modelOverride": "zcode-default",
|
||||||
|
"finalAnswer": "收到。",
|
||||||
|
"changedFiles": [],
|
||||||
|
"verification": [],
|
||||||
|
"remaining": [],
|
||||||
|
"eventsUrl": "/api/workflow-runs/:id/events",
|
||||||
|
"createdAt": 0,
|
||||||
|
"completedAt": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`## FINAL_ANSWER` 只作为旧 run 或非专用 workflow 的兼容保护,不是长期主路径。长期主路径是 worker 主回答自然、Board receipt 结构化。
|
||||||
|
|
||||||
|
## 4. Board 和 MNote 的双向接口
|
||||||
|
|
||||||
|
### 4.1 Board route result
|
||||||
|
|
||||||
|
Board 提供 MNote surface 的推荐结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "agent_board.page_ai_route.v2",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"workflowId": "builtin-mnote-page-ai-chat",
|
||||||
|
"workflowName": "MNote 页面 AI",
|
||||||
|
"workerPresetId": "mnote-page-ai-zcode",
|
||||||
|
"workerName": "MNote 页面 AI · ZCode",
|
||||||
|
"modelOverride": "zcode-default",
|
||||||
|
"allowedWorkerPresetIds": ["mnote-page-ai-zcode"],
|
||||||
|
"allowedWorkflowIds": ["builtin-mnote-page-ai-chat"],
|
||||||
|
"modelOptions": [
|
||||||
|
{ "id": "zcode-default", "label": "默认", "default": true },
|
||||||
|
{ "id": "zcode-fast", "label": "快速" },
|
||||||
|
{ "id": "zcode-strong", "label": "强力" }
|
||||||
|
],
|
||||||
|
"requiresConfirmation": false,
|
||||||
|
"requires": {
|
||||||
|
"filesystem": true,
|
||||||
|
"write": false,
|
||||||
|
"browser": false,
|
||||||
|
"vision": false
|
||||||
|
},
|
||||||
|
"stages": ["answer"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
MNote 消费 Board 推荐,但仍按本地白名单过滤。这样 Board 可以逐步提供更多能力,MNote 不会把控制台复杂度泄露给页面用户。
|
||||||
|
|
||||||
|
### 4.2 MNote run request
|
||||||
|
|
||||||
|
MNote 创建 run 时必须显式传入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"workflowId": "builtin-mnote-page-ai-chat",
|
||||||
|
"workerPresetId": "mnote-page-ai-zcode",
|
||||||
|
"modelOverride": "zcode-default",
|
||||||
|
"capabilityEnvelope": {
|
||||||
|
"primaryTarget": { "absolutePath": "/.../current.md" },
|
||||||
|
"allowedRoots": ["/..."]
|
||||||
|
},
|
||||||
|
"sessionId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Board 必须拒绝不匹配 `surface=mnote-page-ai` 的 worker/workflow 组合,避免前端绕过白名单。
|
||||||
|
|
||||||
|
## 5. 标准页面 AI 最小体验
|
||||||
|
|
||||||
|
按 VSCode/Cursor 类页面 AI 衡量,第一阶段只补核心能力,不把 QA/browser/artifact 做成默认入口。
|
||||||
|
|
||||||
|
### 5.1 Chat
|
||||||
|
|
||||||
|
- [ ] 主聊天只显示用户消息和 assistant 自然回答。
|
||||||
|
- [ ] pending skeleton 或流式 token。
|
||||||
|
- [ ] stop 当前 run。
|
||||||
|
- [x] retry 同一消息。
|
||||||
|
- [ ] copy 回答。
|
||||||
|
- [ ] Enter 发送,Shift+Enter 换行。
|
||||||
|
|
||||||
|
### 5.2 Context
|
||||||
|
|
||||||
|
- [ ] composer 上方显示上下文 pills:当前页、选区、打开资源、文件夹、知识库。
|
||||||
|
- [ ] 当前 write target 单独显示。
|
||||||
|
- [ ] 无写权限时,编辑类 prompt 发送前提示授权缺失。
|
||||||
|
- [ ] `allowedRoots` 与 `primaryTarget` run 前可预检。
|
||||||
|
|
||||||
|
### 5.3 Worker / Model / Workflow
|
||||||
|
|
||||||
|
- [ ] 只展示 MNote Page AI 白名单 worker。
|
||||||
|
- [ ] 当只有一个 worker 时,不显示伪切换入口。
|
||||||
|
- [ ] 模型选择挂在当前 worker 下。
|
||||||
|
- [ ] 只展示 MNote Page AI 白名单 workflow。
|
||||||
|
- [ ] 切换 worker/model/workflow 后写入偏好和 session metadata。
|
||||||
|
|
||||||
|
### 5.4 Timeline / Diff / Receipt
|
||||||
|
|
||||||
|
- [ ] run detail 展示 Board event timeline。
|
||||||
|
- [ ] changed files 从 receipt 读取。
|
||||||
|
- [ ] Markdown 编辑后展示最小 diff 摘要。
|
||||||
|
- [ ] 命令输出默认折叠,失败时展开。
|
||||||
|
- [x] dirty/stale guard:编辑器未保存或文件外部变化时阻止自动写入。
|
||||||
|
|
||||||
|
### 5.5 Session / History
|
||||||
|
|
||||||
|
- [ ] 会话列表可见、可搜索、可恢复。
|
||||||
|
- [ ] 每条 assistant 消息关联 run id、worker、model、workflow、receipt。
|
||||||
|
- [ ] 历史详情可查看 final answer、timeline、changed files。
|
||||||
|
- [ ] 支持删除本地 history,不删除 Board 原始 run。
|
||||||
|
|
||||||
|
## 6. 非默认扩展
|
||||||
|
|
||||||
|
QA、浏览器截图和 artifact 展示不属于页面 AI 第一阶段核心体验。它们只在用户显式选择已加入白名单的 QA/browser workflow,或 Board route 判断确实需要浏览器验证并得到 UI 提示时出现。
|
||||||
|
|
||||||
|
- 默认 Page AI 不显示 QA/browser 入口。
|
||||||
|
- artifact screenshot 只从 run detail 打开,不进入主聊天。
|
||||||
|
- QA run 失败时主聊天只显示一句可理解失败原因,详情里展示日志。
|
||||||
|
|
||||||
|
## 7. 实施计划
|
||||||
|
|
||||||
|
### Phase A:源头回答收敛
|
||||||
|
|
||||||
|
- [x] Board 默认 worker 已改为 `mnote-page-ai-zcode`。
|
||||||
|
- [x] Board 默认 workflow 已改为 `builtin-mnote-page-ai-chat`。
|
||||||
|
- [x] MNote UI 已能只展示 final answer,不展示 Board run 创建日志。
|
||||||
|
- [x] smoke:`hi,收到请回复收到。` 最终 assistant 气泡只显示 `收到。`。
|
||||||
|
- [x] `mnote-page-ai-zcode` / `builtin-mnote-page-ai-chat` 源头默认输出自然语言 final answer。
|
||||||
|
- [x] 结构化记录写入 `receipt.finalAnswer / changedFiles / verification / remaining`。
|
||||||
|
|
||||||
|
### Phase B:白名单选择壳
|
||||||
|
|
||||||
|
- [x] MNote 定义 Page AI worker 白名单。
|
||||||
|
- [x] MNote 定义 Page AI workflow 白名单。
|
||||||
|
- [x] Worker picker 只展示白名单 worker。
|
||||||
|
- [x] Workflow picker 只展示白名单 workflow。
|
||||||
|
- [x] 当白名单只有一个 worker/workflow 时,不显示伪切换。
|
||||||
|
|
||||||
|
### Phase C:worker 内模型选择
|
||||||
|
|
||||||
|
- [x] Board route 返回 `modelOptions`。
|
||||||
|
- [x] MNote 显示当前 model chip。
|
||||||
|
- [x] Model picker 只展示当前 worker 允许的模型档位。
|
||||||
|
- [x] run payload 包含 `modelOverride`。
|
||||||
|
- [x] session metadata 记录 `modelOverride`。
|
||||||
|
|
||||||
|
### Phase D:Board-first session/history
|
||||||
|
|
||||||
|
- [x] 定义并落地 `mnote.page_ai.session.v2`。
|
||||||
|
- [x] assistant message 记录 `boardRunId/workflowId/workerPresetId/modelOverride/receiptId`。
|
||||||
|
- [x] 历史列表展示 Board-first session。
|
||||||
|
- [x] Run detail 可从历史打开。
|
||||||
|
- [x] 刷新页面后仍能恢复会话和 run 详情。
|
||||||
|
|
||||||
|
### Phase E:timeline / changed files / diff
|
||||||
|
|
||||||
|
- [x] 从 Board events 生成 timeline。
|
||||||
|
- [x] 从 receipt 提取 changed files。
|
||||||
|
- [x] Markdown 文件编辑后展示最小 diff / changed files 摘要。
|
||||||
|
- [x] 命令输出和日志折叠。
|
||||||
|
- [x] smoke:编辑当前页面后,主聊天是自然回复,详情里能看到 changed file 和验证命令。
|
||||||
|
|
||||||
|
### Phase F:标准页面 AI 体验补齐
|
||||||
|
|
||||||
|
- [ ] 上下文 pills 完整可交互。
|
||||||
|
- [x] pending skeleton / streaming 状态。
|
||||||
|
- [x] stop / retry / copy。
|
||||||
|
- [x] dirty/stale guard。
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
### 8.1 简单问答
|
||||||
|
|
||||||
|
- 输入:`hi,收到请回复收到。`
|
||||||
|
- 主聊天最新 assistant 气泡:`收到。`
|
||||||
|
- worker 源头最终回答就是自然语言,不依赖 MNote 截断通用任务报告。
|
||||||
|
- run detail 可查看 worker、model、workflow、run id、receipt。
|
||||||
|
|
||||||
|
### 8.2 Worker 与模型
|
||||||
|
|
||||||
|
- UI 只展示 MNote Page AI 白名单 worker。
|
||||||
|
- 白名单只有一个 worker 时,不显示 worker 切换列表。
|
||||||
|
- UI 能切换当前 worker 允许的模型档位。
|
||||||
|
- 发送后 payload 包含 `workerPresetId` 和 `modelOverride`。
|
||||||
|
- 不在白名单内的 worker 不显示。
|
||||||
|
|
||||||
|
### 8.3 Workflow
|
||||||
|
|
||||||
|
- UI 只展示 MNote Page AI 白名单 workflow。
|
||||||
|
- 默认 workflow 为 `builtin-mnote-page-ai-chat`。
|
||||||
|
- 不在白名单内的 Board workflow 不显示。
|
||||||
|
- 发送后 payload 包含 `workflowId`。
|
||||||
|
|
||||||
|
### 8.4 Session/history
|
||||||
|
|
||||||
|
- 新建会话、恢复会话、搜索历史可用。
|
||||||
|
- 历史项显示 final answer preview、状态、时间。
|
||||||
|
- 点击历史项恢复聊天消息。
|
||||||
|
- 点击 run detail 可查看 Board receipt。
|
||||||
|
|
||||||
|
### 8.5 页面编辑
|
||||||
|
|
||||||
|
- 输入“把当前页面中的 A 替换为 B”。
|
||||||
|
- worker 只修改 `primaryTarget.absolutePath`。
|
||||||
|
- 磁盘文件变化。
|
||||||
|
- 编辑器 watcher 刷新可见。
|
||||||
|
- 主聊天显示自然结果。
|
||||||
|
- 详情显示 changed files / verification。
|
||||||
|
|
||||||
|
## 9. 非目标
|
||||||
|
|
||||||
|
- 不重新实现 provider adapter;provider/model 执行仍归 Board。
|
||||||
|
- 不暴露 Board 全量 worker/workflow catalog。
|
||||||
|
- 不把复杂开发、hotfix、QA/browser workflow 默认化。
|
||||||
|
- 不删除 Hermes/Reasonix legacy 代码,只隐藏默认入口并防止污染默认主链。
|
||||||
|
- 不实现完整撤销系统,只保留 diff/receipt 所需数据结构。
|
||||||
|
|
||||||
|
## 10. 风险与处理
|
||||||
|
|
||||||
|
| 风险 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| MNote 专用 worker 仍输出任务报告 | 从 worker/workflow prompt 和 receipt API 源头修正,MNote 截取只做兼容保护 |
|
||||||
|
| Board 选择太多 | MNote 只展示白名单 worker/workflow,新增项必须代码登记 |
|
||||||
|
| 模型选择重新变成 provider picker | 模型挂在特定 worker 下,只展示该 worker 允许的档位 |
|
||||||
|
| Workflow 太重影响简单问答 | 默认只启用 `builtin-mnote-page-ai-chat`,复杂 workflow 需 MNote 白名单 |
|
||||||
|
| 历史数据分裂 | 新 session v2 记录 Board run,旧 session 只读兼容 |
|
||||||
|
| 文件编辑误伤 | primaryTarget + allowedRoots + dirty/stale guard + receipt |
|
||||||
|
|
||||||
|
## 11. 最小落地顺序
|
||||||
|
|
||||||
|
1. 先把 MNote 专用 worker/workflow 的源头输出改成自然回答。
|
||||||
|
2. 再把 Page AI UI 收敛为白名单 worker/workflow,不显示 Board 全量选择。
|
||||||
|
3. 然后补 worker 内模型选择。
|
||||||
|
4. 再补 Board-first session/history。
|
||||||
|
5. 最后补 timeline、diff、dirty/stale guard 和可选扩展。
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# 7-64 [recycle] CodexMobile 嵌入 Page AI 方案 v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-23
|
||||||
|
>
|
||||||
|
> 当前状态:`RECYCLE`
|
||||||
|
>
|
||||||
|
> Owner:07-ai / Page AI / CodexMobile embed
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `design/07-ai/done/7-62-page-ai-board-first-full-rewrite-v1.md`
|
||||||
|
> - `design/07-ai/process/7-63-page-ai-board-first-productization-v1.md`
|
||||||
|
> - `design/07-ai/done/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
|
||||||
|
|
||||||
|
> 过时原因:已由 `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md` 替代;Page AI 新主路径只接入 opencode 官方 runtime + 官方 WebUI。
|
||||||
|
|
||||||
|
## 1. 核心结论
|
||||||
|
|
||||||
|
放弃 Board-first 和自研聊天 UI 两条路,改为 **fork CodexMobile 开源代码 → 精简 → iframe 嵌入 MNote**。
|
||||||
|
|
||||||
|
```text
|
||||||
|
MNote Page AI = MNote 原生上下文 pills + CodexMobile 精简版 iframe
|
||||||
|
CodexMobile = Vue 3 SPA + Express server → Codex CLI app-server (RPC)
|
||||||
|
```
|
||||||
|
|
||||||
|
理由:
|
||||||
|
- CodexMobile(`friuns2/codex-mobile`,MIT,675⭐)已提供成熟的聊天 UI、流式渲染、session 管理、plan/tool 卡片
|
||||||
|
- 当前 MNote Page AI runtime(4457 行 JS)+ Rust bridge(~20000 行)维护成本高,且聊天体验不如 CodexMobile
|
||||||
|
- CodexMobile 直接对接 Codex CLI,CodexRelay 已解决 DeepSeek 兼容,不需要 Board 中介
|
||||||
|
- 嵌入方案比自研省 90% 代码量,比 Board-first 少一层依赖
|
||||||
|
|
||||||
|
## 2. CodexMobile 现状
|
||||||
|
|
||||||
|
### 2.1 仓库信息
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 仓库 | `friuns2/codex-mobile` |
|
||||||
|
| 协议 | MIT |
|
||||||
|
| 星数 | 675 |
|
||||||
|
| 语言 | TypeScript (Vue 3 + Express) |
|
||||||
|
| 本地版本 | v0.1.90 (`/home/lix/.local/lib/node_modules/codexapp/`) |
|
||||||
|
| 当前运行 | `codexapp --no-login --no-tunnel --no-open --port 5900` |
|
||||||
|
|
||||||
|
### 2.2 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 Vue SPA (hash router)
|
||||||
|
↓ fetch /codex-api/rpc
|
||||||
|
Express httpServer.ts
|
||||||
|
↓ codexAppServerBridge.ts
|
||||||
|
Codex CLI app-server (RPC over HTTP)
|
||||||
|
↓
|
||||||
|
Codex CLI (实际 agent 执行)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 核心模块
|
||||||
|
|
||||||
|
| 模块 | 文件 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| RPC 桥接 | `codexAppServerBridge.ts` (~2500行) | 代理所有 Codex RPC 调用 |
|
||||||
|
| API 网关 | `codexGateway.ts` | 线程/消息/模型/账户等高层 API |
|
||||||
|
| RPC 客户端 | `codexRpcClient.ts` | 底层 RPC 调用与错误处理 |
|
||||||
|
| HTTP 服务 | `httpServer.ts` | Express + WebSocket + 静态文件 |
|
||||||
|
| 聊天 UI | `ThreadConversation.vue` | 消息气泡、plan 卡片、工具卡片 |
|
||||||
|
| 输入框 | `ThreadComposer.vue` | 消息输入、发送、模型选择 |
|
||||||
|
| 侧边栏 | `SidebarThreadTree.vue` | 线程列表、搜索、切换 |
|
||||||
|
| 布局 | `DesktopLayout.vue` | 整体布局框架 |
|
||||||
|
|
||||||
|
## 3. 精简方案
|
||||||
|
|
||||||
|
### 3.1 删除清单
|
||||||
|
|
||||||
|
| 删除模块 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| xterm 终端 (`terminalManager.ts`, `ThreadTerminalPanel.vue`) | MNote 不是终端 |
|
||||||
|
| 文件浏览 (`localBrowseUi.ts`) | MNote 有 FileTree |
|
||||||
|
| Firebase auth | MNote 用 SQLite control-plane |
|
||||||
|
| Telegram bridge (`telegramThreadBridge.ts`) | 无关 |
|
||||||
|
| Composio 集成 | 无关 |
|
||||||
|
| OpenRouter proxy (`openRouterProxy.ts`) | 无关 |
|
||||||
|
| Zen proxy (`zenProxy.ts`) | 无关 |
|
||||||
|
| Custom endpoint proxy (`customEndpointProxy.ts`) | 无关 |
|
||||||
|
| Free mode (`freeMode.ts`) | 无关 |
|
||||||
|
| Skills routes/hub (`skillsRoutes.ts`, `SkillsHub.vue`, `SkillCard.vue`) | 无关 |
|
||||||
|
| Review git (`reviewGit.ts`, `ReviewPane.vue`) | 无关 |
|
||||||
|
| Automations (`AutomationsPanel.vue`) | 无关 |
|
||||||
|
| Directory hub (`DirectoryHub.vue`) | 无关 |
|
||||||
|
| Account menu (`AccountMenu.vue`) | 简化 |
|
||||||
|
| Rate limit status (`RateLimitStatus.vue`) | 无关 |
|
||||||
|
| Dictation (`useDictation.ts`) | 无关 |
|
||||||
|
| GitHub skills sync (`useGithubSkillsSync.ts`) | 无关 |
|
||||||
|
| API methods panel (`ApiMethodsPanel.vue`) | 调试工具 |
|
||||||
|
| Pending request panel (`ThreadPendingRequestPanel.vue`) | 简化 |
|
||||||
|
| Queued messages (`QueuedMessages.vue`) | 简化 |
|
||||||
|
| Composer dropdowns (runtime/search/skill picker) | 简化 |
|
||||||
|
| Header git branch dropdown | 无关 |
|
||||||
|
|
||||||
|
### 3.2 保留清单
|
||||||
|
|
||||||
|
| 保留模块 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `codexAppServerBridge.ts` | 核心 RPC 桥接 |
|
||||||
|
| `codexGateway.ts` | 高层 API |
|
||||||
|
| `codexRpcClient.ts` | RPC 客户端 |
|
||||||
|
| `httpServer.ts` | Express + WebSocket |
|
||||||
|
| `authMiddleware.ts` | 简化为 MNote token 验证 |
|
||||||
|
| `ThreadConversation.vue` | 聊天气泡 |
|
||||||
|
| `ThreadComposer.vue` | 输入框 |
|
||||||
|
| `SidebarThreadTree.vue` | 线程列表 |
|
||||||
|
| `DesktopLayout.vue` | 布局 |
|
||||||
|
| `ContentHeader.vue` | 顶部信息 |
|
||||||
|
| `appServerDtos.ts` | 类型定义 |
|
||||||
|
| `codexErrors.ts` | 错误处理 |
|
||||||
|
| WebSocket 流式事件 | 实时消息 |
|
||||||
|
|
||||||
|
### 3.3 新增:postMessage Bridge
|
||||||
|
|
||||||
|
MNote 原生层与 CodexMobile iframe 之间的通信协议:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// MNote → CodexMobile
|
||||||
|
interface MnoteContextMessage {
|
||||||
|
type: 'mnote:context';
|
||||||
|
payload: {
|
||||||
|
workspaceId: string;
|
||||||
|
documentId: string;
|
||||||
|
pageTitle: string;
|
||||||
|
rootUri: string; // file:///...
|
||||||
|
primaryTarget: {
|
||||||
|
absolutePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
};
|
||||||
|
selection?: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
allowedRoots: Array<{
|
||||||
|
rootUri: string;
|
||||||
|
permission: 'read' | 'write';
|
||||||
|
}>;
|
||||||
|
knowledgeContext?: string; // LightRAG 查询结果
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodexMobile → MNote
|
||||||
|
interface CodexMobileEvent {
|
||||||
|
type: 'codex:file-changed' | 'codex:session-update' | 'codex:ready';
|
||||||
|
payload: {
|
||||||
|
changedFiles?: string[];
|
||||||
|
sessionId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 集成架构
|
||||||
|
|
||||||
|
### 4.1 整体拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ MNote Rust SSR (localhost:3000) │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Page AI Panel ──────────────────────────────┐ │
|
||||||
|
│ │ ┌─ MNote 原生上下文 pills ──────────────────┐ │ │
|
||||||
|
│ │ │ [当前页] [选区] [知识库] [allowed roots] │ │ │
|
||||||
|
│ │ └────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌─ iframe ───────────────────────────────────┐ │ │
|
||||||
|
│ │ │ CodexMobile 精简版 SPA │ │ │
|
||||||
|
│ │ │ (Vue 3, hash router, 独立端口 5900) │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ ThreadConversation + ThreadComposer │ │ │
|
||||||
|
│ │ └────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ postMessage ↑↓ │ │
|
||||||
|
│ └────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ /codex-mobile/* → reverse proxy → 127.0.0.1:5900 │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
CodexMobile Express (5900)
|
||||||
|
↓
|
||||||
|
Codex CLI app-server (RPC)
|
||||||
|
↓
|
||||||
|
Codex CLI + CodexRelay → DeepSeek
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Rust 侧改动
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// mnote-web routes/mod.rs 新增
|
||||||
|
.route("/codex-mobile/{*path}", any(codex_mobile_proxy))
|
||||||
|
|
||||||
|
// codex_mobile_proxy: 反向代理到 127.0.0.1:5900
|
||||||
|
// 仅对已认证 session 放行
|
||||||
|
// 注入 X-Mnote-Workspace-Id / X-Mnote-Document-Id header
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 前端侧改动
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// sidebar-page-ai-runtime.js 精简为:
|
||||||
|
// 1. 渲染上下文 pills(当前页、选区、知识库)
|
||||||
|
// 2. 创建 iframe 指向 /codex-mobile/
|
||||||
|
// 3. postMessage 监听与转发
|
||||||
|
// 4. 文件变更事件 → watcher 刷新编辑器
|
||||||
|
|
||||||
|
// 删除:
|
||||||
|
// - 所有 provider 选择逻辑 (Hermes/Reasonix/Chat-only)
|
||||||
|
// - 所有 session/message/run 持久化逻辑
|
||||||
|
// - 所有 Board bridge 调用
|
||||||
|
// - 所有 workflow/worker 选择器
|
||||||
|
// 预计从 4457 行精简到 ~300 行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Rust 侧删除
|
||||||
|
|
||||||
|
```text
|
||||||
|
删除文件:
|
||||||
|
- routes/page_ai_board.rs (317 行)
|
||||||
|
- routes/page_ai_workflow.rs (967 行)
|
||||||
|
- routes/hermes_client.rs 中 Page AI 相关部分
|
||||||
|
|
||||||
|
保留:
|
||||||
|
- hermes_tools/doc.rs 中的 mnote.doc.* tools(非 Page AI 路径仍需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 上下文注入机制
|
||||||
|
|
||||||
|
### 5.1 注入时机
|
||||||
|
|
||||||
|
1. Page AI 面板打开时:注入当前页路径、workspaceId、documentId
|
||||||
|
2. 用户选中文本时:注入 selection
|
||||||
|
3. 用户切换页面时:更新 primaryTarget
|
||||||
|
4. 用户触发知识库查询时:注入 knowledgeContext
|
||||||
|
|
||||||
|
### 5.2 注入方式
|
||||||
|
|
||||||
|
MNote 通过 `iframe.contentWindow.postMessage()` 发送 `mnote:context`,CodexMobile 内新增 `useMnoteBridge.ts` composable 接收并注入到 Codex CLI 的 system prompt 中。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CodexMobile 侧新增 useMnoteBridge.ts
|
||||||
|
// 监听 postMessage,将上下文追加到 thread/start 的 system prompt
|
||||||
|
// ponytail: 最小实现,只做 system prompt 注入,不做 UI 展示
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 文件编辑闭环
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入 "把当前页的 A 替换为 B"
|
||||||
|
→ Codex CLI 通过 CodexRelay 执行
|
||||||
|
→ Codex CLI 直接编辑 primaryTarget.absolutePath
|
||||||
|
→ 磁盘文件变化
|
||||||
|
→ CodexMobile 检测文件变更 → postMessage 'codex:file-changed'
|
||||||
|
→ MNote watcher 检测到文件变化 → 刷新 tiptap 编辑器
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 实施计划
|
||||||
|
|
||||||
|
### Phase A:Fork 与精简(1天)
|
||||||
|
|
||||||
|
- [ ] `git clone https://github.com/friuns2/codex-mobile` 到 `/mnt/Data1T/mnote/codex-mobile/`
|
||||||
|
- [ ] 删除 3.1 清单中的所有模块
|
||||||
|
- [ ] 简化 `authMiddleware.ts`:接受 MNote session token
|
||||||
|
- [ ] 新增 `useMnoteBridge.ts`:postMessage 监听
|
||||||
|
- [ ] 验证 `npm run build` 通过
|
||||||
|
- [ ] 验证精简版可独立运行(`codexapp --port 5900`)
|
||||||
|
|
||||||
|
### Phase B:MNote 集成(1天)
|
||||||
|
|
||||||
|
- [ ] Rust 新增 `/codex-mobile/{*path}` 反向代理路由
|
||||||
|
- [ ] `sidebar-page-ai-runtime.js` 精简为上下文 pills + iframe + postMessage
|
||||||
|
- [ ] 删除 `page_ai_board.rs`、`page_ai_workflow.rs`
|
||||||
|
- [ ] 上下文 pills 实现(当前页、选区、知识库引用)
|
||||||
|
- [ ] postMessage bridge 双向通信验证
|
||||||
|
|
||||||
|
### Phase C:闭环验证(0.5天)
|
||||||
|
|
||||||
|
- [ ] smoke:打开 Page AI → 看到 CodexMobile 聊天界面
|
||||||
|
- [ ] smoke:发送消息 → 流式回复可见
|
||||||
|
- [ ] smoke:编辑当前页 → 文件变化 → 编辑器刷新
|
||||||
|
- [ ] smoke:刷新页面 → 会话恢复
|
||||||
|
- [ ] smoke:上下文 pills 正确显示当前页信息
|
||||||
|
|
||||||
|
### Phase D:旧代码清理(后续)
|
||||||
|
|
||||||
|
- [ ] 归档 `sidebar-page-ai-runtime.js` 旧代码
|
||||||
|
- [ ] 归档 `hermes_client.rs` Page AI 相关代码
|
||||||
|
- [ ] 更新 `ARCHITECTURE.md` 和 `CURRENT_ARCHITECTURE.md`
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
### 7.1 聊天体验
|
||||||
|
|
||||||
|
- 用户可在 Page AI 面板中与 Codex 对话
|
||||||
|
- 流式回复实时可见
|
||||||
|
- 支持 stop / retry / copy
|
||||||
|
- 刷新页面后会话历史可恢复
|
||||||
|
- plan 卡片和工具调用卡片可见(含 plan 持久化补丁)
|
||||||
|
|
||||||
|
### 7.2 上下文注入
|
||||||
|
|
||||||
|
- 上下文 pills 显示当前页标题和路径
|
||||||
|
- 选区内容自动注入
|
||||||
|
- Codex 能读取和编辑当前页文件
|
||||||
|
- 文件编辑后 MNote 编辑器自动刷新
|
||||||
|
|
||||||
|
### 7.3 代码精简
|
||||||
|
|
||||||
|
- `sidebar-page-ai-runtime.js` 从 4457 行精简到 <500 行
|
||||||
|
- 删除 `page_ai_board.rs` 和 `page_ai_workflow.rs`(~1300 行)
|
||||||
|
- 不再依赖 Agent Board 服务
|
||||||
|
|
||||||
|
## 8. 风险与处理
|
||||||
|
|
||||||
|
| 风险 | 处理 |
|
||||||
|
|---|---|
|
||||||
|
| CodexMobile 上游更新导致 fork 过时 | 定期 rebase,只保留精简 diff |
|
||||||
|
| Codex CLI 不可用 | 降级提示 "Codex CLI 未运行",不伪装可用 |
|
||||||
|
| iframe 跨域问题 | 同源反向代理(`/codex-mobile/*`),无跨域 |
|
||||||
|
| postMessage 安全 | 验证 origin,只接受已知消息类型 |
|
||||||
|
| plan 卡片刷新消失 | 已有 `codexmobile-plan-persist.js` 补丁,合入 fork |
|
||||||
|
| npm 升级清空 codexapp 目录 | fork 到 MNote 仓库内,不依赖 npm 全局安装 |
|
||||||
|
|
||||||
|
## 9. 与旧方案的对比
|
||||||
|
|
||||||
|
| | Board-first (7-62/7-63) | CodexMobile 嵌入 (本方案) |
|
||||||
|
|---|---|---|
|
||||||
|
| MNote 代码量 | ~4457 JS + ~20000 Rust | ~500 JS + ~50 Rust |
|
||||||
|
| 聊天 UI 成熟度 | 自研,持续打磨 | 675⭐ 开源验证 |
|
||||||
|
| 依赖服务 | Agent Board (必须) | Codex CLI (必须) |
|
||||||
|
| Agent 选择 | 通过 Board 间接 | 直接使用 Codex CLI |
|
||||||
|
| Provider 适配 | Board 负责 | CodexRelay 负责 |
|
||||||
|
| 上下文注入 | 自研 pills | 自研 pills + system prompt |
|
||||||
|
| 维护负担 | Board bridge + 聊天 UI | 追上游 + postMessage bridge |
|
||||||
|
| 差异化 | 上下文 pills | 上下文 pills + 成熟聊天体验 |
|
||||||
|
|
||||||
|
## 10. 非目标
|
||||||
|
|
||||||
|
- 不把 CodexMobile 的终端、文件浏览、Skills、Review 等功能带入 MNote
|
||||||
|
- 不替换 MNote 的 FileTree、编辑器、知识库等核心功能
|
||||||
|
- 不要求 CodexMobile 支持 MNote 特有的资源类型(mindmap、OnlyOffice)
|
||||||
|
- 不实现 CodexMobile 与 MNote auth 的 SSO 统一(短期独立 auth,长期可选)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// MNote UI 基础运行时:全局 portal 与 toast API。
|
||||||
|
(function initMnoteUiRuntime() {
|
||||||
|
if (window.__mnoteUiRuntimeStarted) return;
|
||||||
|
window.__mnoteUiRuntimeStarted = true;
|
||||||
|
|
||||||
|
function ensurePortalRoot() {
|
||||||
|
var root = document.getElementById('mnote-portal-root');
|
||||||
|
if (root instanceof HTMLElement) return root;
|
||||||
|
root = document.createElement('div');
|
||||||
|
root.id = 'mnote-portal-root';
|
||||||
|
root.setAttribute('data-mnote-portal-root', 'true');
|
||||||
|
document.body.appendChild(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureToastRegion() {
|
||||||
|
var root = ensurePortalRoot();
|
||||||
|
var region = root.querySelector('[data-mnote-toast-region="true"]');
|
||||||
|
if (region instanceof HTMLElement) return region;
|
||||||
|
region = document.createElement('div');
|
||||||
|
region.className = 'mnote-toast-region';
|
||||||
|
region.setAttribute('data-mnote-toast-region', 'true');
|
||||||
|
region.setAttribute('role', 'status');
|
||||||
|
region.setAttribute('aria-live', 'polite');
|
||||||
|
root.appendChild(region);
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message, options) {
|
||||||
|
var text = String(message || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
var config = options || {};
|
||||||
|
var kind = String(config.kind || config.type || 'info').trim() || 'info';
|
||||||
|
var timeoutMs = Number(config.timeoutMs || config.duration || 3200);
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 800) timeoutMs = 3200;
|
||||||
|
var region = ensureToastRegion();
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'mnote-toast mnote-toast--' + kind;
|
||||||
|
item.setAttribute('data-mnote-toast', 'true');
|
||||||
|
item.setAttribute('data-kind', kind);
|
||||||
|
item.textContent = text;
|
||||||
|
region.appendChild(item);
|
||||||
|
requestAnimationFrame(function showToast() {
|
||||||
|
item.classList.add('mnote-toast--visible');
|
||||||
|
});
|
||||||
|
window.setTimeout(function hideToast() {
|
||||||
|
item.classList.remove('mnote-toast--visible');
|
||||||
|
window.setTimeout(function removeToast() {
|
||||||
|
if (item.parentNode) item.parentNode.removeChild(item);
|
||||||
|
}, 180);
|
||||||
|
}, timeoutMs);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mnote = window.mnote || {};
|
||||||
|
window.mnote.ensurePortalRoot = ensurePortalRoot;
|
||||||
|
window.mnote.toast = toast;
|
||||||
|
|
||||||
|
window.addEventListener('mnote:toast', function onMnoteToast(event) {
|
||||||
|
var detail = event && event.detail ? event.detail : {};
|
||||||
|
toast(detail.message, detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', ensurePortalRoot, { once: true });
|
||||||
|
} else {
|
||||||
|
ensurePortalRoot();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -123,6 +123,7 @@ export function createSidebarPageAiProfileRuntime(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pageAiSessionAgentFilterValue(session) {
|
function pageAiSessionAgentFilterValue(session) {
|
||||||
|
if (String(session && session.source || '') === 'board' || session && session.workerPresetId) return 'board:mnote-page-ai';
|
||||||
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
||||||
if (agentId === 'reasonix') return 'reasonix';
|
if (agentId === 'reasonix') return 'reasonix';
|
||||||
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
||||||
@@ -131,6 +132,11 @@ export function createSidebarPageAiProfileRuntime(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pageAiSessionAgentLabel(session) {
|
function pageAiSessionAgentLabel(session) {
|
||||||
|
if (String(session && session.source || '') === 'board' || session && session.workerPresetId) {
|
||||||
|
var worker = String(session && session.workerPresetId || 'mnote-page-ai-zcode').trim();
|
||||||
|
var model = String(session && session.modelOverride || '').trim();
|
||||||
|
return 'Agent Board / ' + worker + (model ? ' / ' + model : '');
|
||||||
|
}
|
||||||
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
||||||
if (agentId === 'reasonix') return pageAiAgentRecord(agentId).label || 'Reasonix';
|
if (agentId === 'reasonix') return pageAiAgentRecord(agentId).label || 'Reasonix';
|
||||||
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
pageAiSkillSourceParts,
|
pageAiSkillSourceParts,
|
||||||
pageAiToggleableSkillEntries,
|
pageAiToggleableSkillEntries,
|
||||||
pageAiUsageSummary,
|
pageAiUsageSummary,
|
||||||
|
pageAiBoardFirstEnabled,
|
||||||
|
pageAiBoardWorkerOptions,
|
||||||
|
pageAiBoardWorkflowOptions,
|
||||||
|
pageAiBoardSelectedWorkerId,
|
||||||
|
pageAiBoardSelectedWorkflowId,
|
||||||
|
pageAiBoardWorkerLabel,
|
||||||
|
pageAiBoardWorkflowLabel,
|
||||||
|
pageAiBoardModelOptions,
|
||||||
|
pageAiBoardSelectedModelOverride,
|
||||||
|
pageAiBoardModelLabel,
|
||||||
pageUiState,
|
pageUiState,
|
||||||
renderPageAiMarkdown,
|
renderPageAiMarkdown,
|
||||||
searchText,
|
searchText,
|
||||||
@@ -62,6 +72,18 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
} = context;
|
} = context;
|
||||||
const PAGE_AI_CONTEXT_REF_REGISTRY = Array.isArray(contextRefRegistry) ? contextRefRegistry : [];
|
const PAGE_AI_CONTEXT_REF_REGISTRY = Array.isArray(contextRefRegistry) ? contextRefRegistry : [];
|
||||||
|
|
||||||
|
function pageAiLocalBoardFirstEnabled() {
|
||||||
|
try {
|
||||||
|
return window.localStorage ? window.localStorage.getItem('mnote.page_ai.legacy_provider_mode') !== '1' : true;
|
||||||
|
} catch (_error) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPageAiBoardFirstEnabled() {
|
||||||
|
return typeof pageAiBoardFirstEnabled === 'function' ? pageAiBoardFirstEnabled() : pageAiLocalBoardFirstEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
function pageAiTargetLabel(target) {
|
function pageAiTargetLabel(target) {
|
||||||
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||||
var kind = String(target && (target.resourceKind || target.editorKind) || workspacePath.resourceKind || '').trim();
|
var kind = String(target && (target.resourceKind || target.editorKind) || workspacePath.resourceKind || '').trim();
|
||||||
@@ -430,6 +452,8 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
|
|
||||||
function renderPageAiProviderButtons() {
|
function renderPageAiProviderButtons() {
|
||||||
var drawer = ensurePageAiDrawer();
|
var drawer = ensurePageAiDrawer();
|
||||||
|
if (drawer.getAttribute('data-page-ai-opencode-host') === 'true') return;
|
||||||
|
var boardFirst = isPageAiBoardFirstEnabled();
|
||||||
var isAcp = pageUiState.pageAiAcpRuntime !== '';
|
var isAcp = pageUiState.pageAiAcpRuntime !== '';
|
||||||
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
|
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
|
||||||
var provider = button.getAttribute('data-page-ai-provider') || 'hermes';
|
var provider = button.getAttribute('data-page-ai-provider') || 'hermes';
|
||||||
@@ -439,12 +463,14 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
});
|
});
|
||||||
var providerNode = drawer.querySelector('.wolai-page-ai-subtitle span:first-child');
|
var providerNode = drawer.querySelector('.wolai-page-ai-subtitle span:first-child');
|
||||||
if (providerNode instanceof HTMLElement) {
|
if (providerNode instanceof HTMLElement) {
|
||||||
providerNode.textContent = pageAiAgentRecord(pageAiCurrentAgentId()).label;
|
providerNode.textContent = boardFirst ? 'Agent Board' : pageAiAgentRecord(pageAiCurrentAgentId()).label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPageAiControls() {
|
function renderPageAiControls() {
|
||||||
var drawer = ensurePageAiDrawer();
|
var drawer = ensurePageAiDrawer();
|
||||||
|
if (drawer.getAttribute('data-page-ai-opencode-host') === 'true') return;
|
||||||
|
var boardFirst = isPageAiBoardFirstEnabled();
|
||||||
var isAcp = pageUiState.pageAiAcpRuntime !== '';
|
var isAcp = pageUiState.pageAiAcpRuntime !== '';
|
||||||
var activeAgentId = pageAiCurrentAgentId();
|
var activeAgentId = pageAiCurrentAgentId();
|
||||||
var activeProfile = pageAiCurrentProfile();
|
var activeProfile = pageAiCurrentProfile();
|
||||||
@@ -456,19 +482,52 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
documentRef.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId);
|
documentRef.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId);
|
||||||
var agentButton = drawer.querySelector('[data-page-ai-agent-button]');
|
var agentButton = drawer.querySelector('[data-page-ai-agent-button]');
|
||||||
var agentPopoverId = 'mnote-page-ai-agent-popover';
|
var agentPopoverId = 'mnote-page-ai-agent-popover';
|
||||||
var activeAgentLabel = pageAiCurrentAgentSelectionLabel();
|
var activeAgentLabel = boardFirst && typeof pageAiBoardWorkerLabel === 'function'
|
||||||
|
? pageAiBoardWorkerLabel() + ' / ' + (typeof pageAiBoardModelLabel === 'function' ? pageAiBoardModelLabel() : '默认')
|
||||||
|
: pageAiCurrentAgentSelectionLabel();
|
||||||
if (agentButton instanceof HTMLElement) {
|
if (agentButton instanceof HTMLElement) {
|
||||||
agentButton.textContent = 'AI';
|
agentButton.textContent = boardFirst ? 'Board' : 'AI';
|
||||||
agentButton.setAttribute('aria-expanded', pageUiState.pageAiAgentPopoverOpen ? 'true' : 'false');
|
agentButton.setAttribute('aria-expanded', pageUiState.pageAiAgentPopoverOpen ? 'true' : 'false');
|
||||||
agentButton.setAttribute('aria-controls', agentPopoverId);
|
agentButton.setAttribute('aria-controls', agentPopoverId);
|
||||||
agentButton.setAttribute('data-page-ai-agent-summary', activeAgentLabel);
|
agentButton.setAttribute('data-page-ai-agent-summary', activeAgentLabel);
|
||||||
agentButton.setAttribute('aria-label', 'Agent:' + activeAgentLabel);
|
agentButton.setAttribute('aria-label', (boardFirst ? '运行控制:' : 'Agent:') + activeAgentLabel);
|
||||||
agentButton.setAttribute('title', 'Agent:' + activeAgentLabel);
|
agentButton.setAttribute('title', (boardFirst ? '运行控制:' : 'Agent:') + activeAgentLabel);
|
||||||
}
|
}
|
||||||
var agentPopover = drawer.querySelector('[data-page-ai-agent-popover]');
|
var agentPopover = drawer.querySelector('[data-page-ai-agent-popover]');
|
||||||
if (agentPopover instanceof HTMLElement) {
|
if (agentPopover instanceof HTMLElement) {
|
||||||
agentPopover.id = agentPopoverId;
|
agentPopover.id = agentPopoverId;
|
||||||
agentPopover.hidden = !pageUiState.pageAiAgentPopoverOpen;
|
agentPopover.hidden = !pageUiState.pageAiAgentPopoverOpen;
|
||||||
|
if (boardFirst) {
|
||||||
|
var selectedWorkerId = typeof pageAiBoardSelectedWorkerId === 'function' ? pageAiBoardSelectedWorkerId() : 'mnote-page-ai-zcode';
|
||||||
|
var selectedWorkflowId = typeof pageAiBoardSelectedWorkflowId === 'function' ? pageAiBoardSelectedWorkflowId() : 'builtin-mnote-page-ai-chat';
|
||||||
|
var workerOptions = typeof pageAiBoardWorkerOptions === 'function' ? pageAiBoardWorkerOptions() : [];
|
||||||
|
var workflowOptions = typeof pageAiBoardWorkflowOptions === 'function' ? pageAiBoardWorkflowOptions() : [];
|
||||||
|
var modelOptions = typeof pageAiBoardModelOptions === 'function' ? pageAiBoardModelOptions(selectedWorkerId) : [];
|
||||||
|
var selectedModelOverride = typeof pageAiBoardSelectedModelOverride === 'function' ? pageAiBoardSelectedModelOverride() : 'zcode-default';
|
||||||
|
var workerSelect = workerOptions.length > 1 ? '<label class="wolai-page-ai-profile-select"><span>Worker</span><select data-page-ai-board-worker>' + workerOptions.map(function(worker) {
|
||||||
|
var id = String(worker.id || '').trim();
|
||||||
|
var label = String(worker.name || worker.id || '').trim();
|
||||||
|
var detail = [worker.agentType, worker.agentModel].filter(Boolean).join(' · ');
|
||||||
|
return '<option value="' + escapeHtml(id) + '"' + (id === selectedWorkerId ? ' selected' : '') + '>' + escapeHtml([label, detail].filter(Boolean).join(' · ')) + '</option>';
|
||||||
|
}).join('') + '</select></label>' : '<div class="wolai-page-ai-profile-select"><span>Worker</span><strong>' + escapeHtml(typeof pageAiBoardWorkerLabel === 'function' ? pageAiBoardWorkerLabel() : 'MNote 页面 AI · ZCode') + '</strong></div>';
|
||||||
|
var workflowSelect = workflowOptions.length > 1 ? '<label class="wolai-page-ai-profile-select"><span>Workflow</span><select data-page-ai-board-workflow>' + workflowOptions.map(function(workflow) {
|
||||||
|
var id = String(workflow.id || '').trim();
|
||||||
|
var label = String(workflow.name || workflow.id || '').trim();
|
||||||
|
return '<option value="' + escapeHtml(id) + '"' + (id === selectedWorkflowId ? ' selected' : '') + '>' + escapeHtml(label) + '</option>';
|
||||||
|
}).join('') + '</select></label>' : '<div class="wolai-page-ai-profile-select"><span>Workflow</span><strong>' + escapeHtml(typeof pageAiBoardWorkflowLabel === 'function' ? pageAiBoardWorkflowLabel() : 'MNote 页面 AI') + '</strong></div>';
|
||||||
|
var modelSelect = '<label class="wolai-page-ai-profile-select"><span>模型</span><select data-page-ai-board-model>' + modelOptions.map(function(option) {
|
||||||
|
var id = String(option.id || '').trim();
|
||||||
|
var label = String(option.label || option.id || '').trim();
|
||||||
|
return '<option value="' + escapeHtml(id) + '"' + (id === selectedModelOverride ? ' selected' : '') + '>' + escapeHtml(label) + '</option>';
|
||||||
|
}).join('') + '</select></label>';
|
||||||
|
agentPopover.innerHTML = '' +
|
||||||
|
'<div class="wolai-page-ai-context-popover-head">' +
|
||||||
|
'<strong>Agent Board</strong>' +
|
||||||
|
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="close-agent-popover">完成</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="wolai-page-ai-settings-grid">' + workerSelect + modelSelect + workflowSelect + '</div>' +
|
||||||
|
'<div class="wolai-page-ai-empty">MNote 只发送 capability/envelope;provider、执行和 workflow 由 Agent Board 负责。</div>';
|
||||||
|
} else {
|
||||||
var chatOnlyOptions = pageAiChatOnlyProfileEntries().map(function(profile) {
|
var chatOnlyOptions = pageAiChatOnlyProfileEntries().map(function(profile) {
|
||||||
var spec = pageAiChatOnlyProfileSpec(profile);
|
var spec = pageAiChatOnlyProfileSpec(profile);
|
||||||
var label = profile.menuLabel || (spec && spec.label) || pageAiProfileDisplayLabel(profile, '');
|
var label = profile.menuLabel || (spec && spec.label) || pageAiProfileDisplayLabel(profile, '');
|
||||||
@@ -509,11 +568,12 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'</button>' +
|
'</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var agentChip = drawer.querySelector('[data-page-ai-agent-chip]');
|
var agentChip = drawer.querySelector('[data-page-ai-agent-chip]');
|
||||||
if (agentChip instanceof HTMLElement) {
|
if (agentChip instanceof HTMLElement) {
|
||||||
agentChip.textContent = activeAgentLabel;
|
agentChip.textContent = boardFirst && typeof pageAiBoardWorkerLabel === 'function' ? pageAiBoardWorkerLabel() + ' / ' + (typeof pageAiBoardModelLabel === 'function' ? pageAiBoardModelLabel() : '默认') : activeAgentLabel;
|
||||||
agentChip.setAttribute('title', '当前 Agent:' + activeAgentLabel);
|
agentChip.setAttribute('title', boardFirst ? '当前 worker/model:' + agentChip.textContent : '当前 Agent:' + activeAgentLabel);
|
||||||
}
|
}
|
||||||
var contextButton = drawer.querySelector('[data-page-ai-context-button]');
|
var contextButton = drawer.querySelector('[data-page-ai-context-button]');
|
||||||
var contextPopoverId = 'mnote-page-ai-context-popover';
|
var contextPopoverId = 'mnote-page-ai-context-popover';
|
||||||
@@ -676,7 +736,10 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix;
|
runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix;
|
||||||
}
|
}
|
||||||
drawer.querySelectorAll('[data-page-ai-runtime-badge]').forEach(function(runtimeBadge) {
|
drawer.querySelectorAll('[data-page-ai-runtime-badge]').forEach(function(runtimeBadge) {
|
||||||
if (runtimeBadge instanceof HTMLElement) runtimeBadge.textContent = pageAiRuntimeStatusLabel();
|
if (runtimeBadge instanceof HTMLElement) {
|
||||||
|
var workflowLabel = boardFirst && typeof pageAiBoardWorkflowLabel === 'function' ? pageAiBoardWorkflowLabel() : '';
|
||||||
|
runtimeBadge.textContent = boardFirst ? 'Agent Board · ' + workflowLabel : pageAiRuntimeStatusLabel();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
var runtimeActive = drawer.querySelector('[data-page-ai-runtime-active-run]');
|
var runtimeActive = drawer.querySelector('[data-page-ai-runtime-active-run]');
|
||||||
if (runtimeActive instanceof HTMLElement) {
|
if (runtimeActive instanceof HTMLElement) {
|
||||||
@@ -684,7 +747,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
runtimeActive.textContent = pageAiRunStatusLabel(activeRunStatus);
|
runtimeActive.textContent = pageAiRunStatusLabel(activeRunStatus);
|
||||||
}
|
}
|
||||||
var runtimeAcp = drawer.querySelector('[data-page-ai-runtime-acp]');
|
var runtimeAcp = drawer.querySelector('[data-page-ai-runtime-acp]');
|
||||||
if (runtimeAcp instanceof HTMLElement) runtimeAcp.textContent = pageAiRuntimeStatusValue('acp.acpSessionId', '无 ACP session');
|
if (runtimeAcp instanceof HTMLElement) runtimeAcp.textContent = boardFirst ? 'provider 由 Board worker 决定' : pageAiRuntimeStatusValue('acp.acpSessionId', '无 ACP session');
|
||||||
var runtimeDetail = drawer.querySelector('[data-page-ai-runtime-detail]');
|
var runtimeDetail = drawer.querySelector('[data-page-ai-runtime-detail]');
|
||||||
if (runtimeDetail instanceof HTMLElement) runtimeDetail.innerHTML = pageAiRuntimeDetailHtml();
|
if (runtimeDetail instanceof HTMLElement) runtimeDetail.innerHTML = pageAiRuntimeDetailHtml();
|
||||||
var jobsList = drawer.querySelector('[data-page-ai-jobs-list]');
|
var jobsList = drawer.querySelector('[data-page-ai-jobs-list]');
|
||||||
@@ -709,12 +772,12 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
}
|
}
|
||||||
var usageDetail = drawer.querySelector('[data-page-ai-usage-detail]');
|
var usageDetail = drawer.querySelector('[data-page-ai-usage-detail]');
|
||||||
if (usageDetail instanceof HTMLElement) usageDetail.innerHTML = pageAiUsageDetailHtml();
|
if (usageDetail instanceof HTMLElement) usageDetail.innerHTML = pageAiUsageDetailHtml();
|
||||||
var stopButton = drawer.querySelector('[data-page-ai-action="stop-run"]');
|
var canStop = ['queued', 'running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0 && pageUiState.pageAiCurrentRunId;
|
||||||
if (stopButton instanceof HTMLButtonElement) {
|
drawer.querySelectorAll('[data-page-ai-action="stop-run"]').forEach(function(stopButton) {
|
||||||
var canStop = ['queued', 'running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0 && pageUiState.pageAiCurrentRunId;
|
if (!(stopButton instanceof HTMLButtonElement)) return;
|
||||||
stopButton.disabled = !canStop;
|
stopButton.disabled = !canStop;
|
||||||
stopButton.setAttribute('aria-disabled', canStop ? 'false' : 'true');
|
stopButton.setAttribute('aria-disabled', canStop ? 'false' : 'true');
|
||||||
}
|
});
|
||||||
var settingsLink = drawer.querySelector('[data-page-ai-action="open-hermes-settings"]');
|
var settingsLink = drawer.querySelector('[data-page-ai-action="open-hermes-settings"]');
|
||||||
if (settingsLink instanceof HTMLButtonElement) {
|
if (settingsLink instanceof HTMLButtonElement) {
|
||||||
settingsLink.disabled = !pageAiHermesSettingsUrl();
|
settingsLink.disabled = !pageAiHermesSettingsUrl();
|
||||||
@@ -771,6 +834,19 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage;
|
panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (boardFirst) {
|
||||||
|
var subtitle = drawer.querySelector('.wolai-page-ai-subtitle');
|
||||||
|
if (subtitle instanceof HTMLElement) {
|
||||||
|
var workerLabel = typeof pageAiBoardWorkerLabel === 'function' ? pageAiBoardWorkerLabel() : 'ZCode 默认 developer';
|
||||||
|
subtitle.innerHTML = '<span>Agent Board</span><span>' + escapeHtml(workerLabel) + '</span><span>local-first 文件编辑</span>';
|
||||||
|
}
|
||||||
|
drawer.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]').forEach(function(node) {
|
||||||
|
if (node instanceof HTMLElement) node.hidden = true;
|
||||||
|
});
|
||||||
|
drawer.querySelectorAll('[data-page-ai-panel="agent"], [data-page-ai-panel="reasonix-settings"], [data-page-ai-panel="hermes-settings"]').forEach(function(node) {
|
||||||
|
if (node instanceof HTMLElement) node.setAttribute('data-page-ai-legacy-provider-panel', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
var profileError = drawer.querySelector('[data-page-ai-profile-error]');
|
var profileError = drawer.querySelector('[data-page-ai-profile-error]');
|
||||||
if (profileError instanceof HTMLElement) {
|
if (profileError instanceof HTMLElement) {
|
||||||
profileError.textContent = pageUiState.pageAiProfileError || '';
|
profileError.textContent = pageUiState.pageAiProfileError || '';
|
||||||
@@ -1068,8 +1144,8 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-header-actions">' +
|
'<div class="wolai-page-ai-header-actions">' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="new-session" aria-label="新建 AI 会话" title="新建 AI 会话">+</button>' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="new-session" aria-label="新建 AI 会话" title="新建 AI 会话"><span class="material-symbols-outlined" data-icon="add" aria-hidden="true"></span></button>' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="history" aria-label="历史会话" title="历史会话">⌕</button>' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="history" aria-label="历史会话" title="历史会话"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="skills" aria-label="能力" title="能力">' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="skills" aria-label="能力" title="能力">' +
|
||||||
'<svg class="wolai-page-ai-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
'<svg class="wolai-page-ai-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
||||||
'<path d="M12 3l1.35 4.15L17.5 8.5l-4.15 1.35L12 14l-1.35-4.15L6.5 8.5l4.15-1.35L12 3z" />' +
|
'<path d="M12 3l1.35 4.15L17.5 8.5l-4.15 1.35L12 14l-1.35-4.15L6.5 8.5l4.15-1.35L12 3z" />' +
|
||||||
@@ -1077,8 +1153,8 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'<path d="M5.5 14l.65 1.85L8 16.5l-1.85.65L5.5 19l-.65-1.85L3 16.5l1.85-.65L5.5 14z" />' +
|
'<path d="M5.5 14l.65 1.85L8 16.5l-1.85.65L5.5 19l-.65-1.85L3 16.5l1.85-.65L5.5 14z" />' +
|
||||||
'</svg>' +
|
'</svg>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="agent" aria-label="打开页面 AI 设置">⚙</button>' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="agent" aria-label="打开页面 AI 设置"><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span></button>' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="close" aria-label="关闭页面 AI"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<button type="button" class="wolai-page-ai-runtime-strip" data-page-ai-runtime-strip data-page-ai-tab="status" aria-label="打开 Page AI 状态">' +
|
'<button type="button" class="wolai-page-ai-runtime-strip" data-page-ai-runtime-strip data-page-ai-tab="status" aria-label="打开 Page AI 状态">' +
|
||||||
@@ -1540,6 +1616,13 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
|
|
||||||
function pageAiRenderNonToolMessage(item) {
|
function pageAiRenderNonToolMessage(item) {
|
||||||
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
|
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
|
||||||
|
if (item.kind === 'status') {
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-status-message="true"' + (item.streaming ? ' data-page-ai-streaming="true"' : '') + '>' +
|
||||||
|
'<div class="wolai-page-ai-message-role">AI</div>' +
|
||||||
|
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
if (item.kind === 'thought') {
|
if (item.kind === 'thought') {
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-collapse-card data-page-ai-collapse-id="thought-single">' +
|
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-collapse-card data-page-ai-collapse-id="thought-single">' +
|
||||||
@@ -1602,10 +1685,14 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
}
|
}
|
||||||
if (pageAiLooksLikeReasonixPlanMessage(item)) return pageAiRenderReasonixPlanCard(item);
|
if (pageAiLooksLikeReasonixPlanMessage(item)) return pageAiRenderReasonixPlanCard(item);
|
||||||
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
||||||
|
var detailButton = item.boardRunId
|
||||||
|
? '<div class="wolai-page-ai-message-actions"><button type="button" class="wolai-page-ai-ghost" data-page-ai-board-run-detail="' + escapeHtml(item.boardRunId) + '">Run detail</button>' + (item.retryPrompt ? '<button type="button" class="wolai-page-ai-ghost" data-page-ai-retry-message="' + escapeHtml(item.boardRunId) + '">重试</button>' : '') + '<button type="button" class="wolai-page-ai-ghost" data-page-ai-copy-message="' + escapeHtml(item.boardRunId) + '">复制</button></div>'
|
||||||
|
: '';
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
||||||
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
|
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
|
||||||
'<div class="wolai-page-ai-message-text">' + (item.role === 'assistant' ? renderPageAiMarkdown(item.content || '') : escapeHtml(item.content || '')) + '</div>' +
|
'<div class="wolai-page-ai-message-text">' + (item.role === 'assistant' ? renderPageAiMarkdown(item.content || '') : escapeHtml(item.content || '')) + '</div>' +
|
||||||
|
detailButton +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,6 +1756,38 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
return titleKey === previewKey || previewKey.indexOf(titleKey) === 0 || titleKey.indexOf(previewKey) === 0;
|
return titleKey === previewKey || previewKey.indexOf(titleKey) === 0 || titleKey.indexOf(previewKey) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiBoardRunDetailHtml(runId) {
|
||||||
|
var current = typeof pageAiCurrentSession === 'function' ? pageAiCurrentSession() : null;
|
||||||
|
var detail = pageUiState.pageAiBoardRunDetails && pageUiState.pageAiBoardRunDetails[runId]
|
||||||
|
|| current && current.boardRuns && current.boardRuns[runId]
|
||||||
|
|| null;
|
||||||
|
if (!detail) return '<div class="wolai-page-ai-empty">暂无 run detail。</div>';
|
||||||
|
var changedFiles = pageAiNormalizeArray(detail.changedFiles).map(function(file) {
|
||||||
|
return '<li>' + escapeHtml(String(file.path || file || '')) + (file.status ? ' · ' + escapeHtml(file.status) : '') + '</li>';
|
||||||
|
}).join('');
|
||||||
|
var verification = pageAiNormalizeArray(detail.verification).map(function(item) {
|
||||||
|
return '<li>' + escapeHtml([item.command, item.status, item.output].filter(Boolean).join(' · ')) + '</li>';
|
||||||
|
}).join('');
|
||||||
|
var timeline = pageAiNormalizeArray(detail.timeline).map(function(event) {
|
||||||
|
return '<li>' + escapeHtml([event.type, event.message, event.timestamp].filter(Boolean).join(' · ')) + '</li>';
|
||||||
|
}).join('');
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-board-run-detail-card="true">' +
|
||||||
|
'<div class="wolai-page-ai-permission-card-header">' +
|
||||||
|
'<div class="wolai-page-ai-plan-card-title-wrap"><span class="wolai-page-ai-plan-card-kicker">Run detail</span><strong class="wolai-page-ai-plan-card-title">' + escapeHtml(detail.runId || runId) + '</strong></div>' +
|
||||||
|
'<span class="wolai-page-ai-permission-badge">' + escapeHtml(detail.status || '') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="wolai-page-ai-permission-summary">' +
|
||||||
|
'<div class="wolai-page-ai-permission-row"><span>Worker</span><strong>' + escapeHtml(detail.workerName || detail.workerPresetId || '') + '</strong></div>' +
|
||||||
|
'<div class="wolai-page-ai-permission-row"><span>Model</span><strong>' + escapeHtml(detail.modelLabel || detail.modelOverride || '') + '</strong></div>' +
|
||||||
|
'<div class="wolai-page-ai-permission-row"><span>Workflow</span><strong>' + escapeHtml(detail.workflowName || detail.workflowId || '') + '</strong></div>' +
|
||||||
|
'</div>' +
|
||||||
|
(changedFiles ? '<details class="wolai-page-ai-tool-details" open><summary><strong>changed files</strong><span>' + String(pageAiNormalizeArray(detail.changedFiles).length) + '</span></summary><ul>' + changedFiles + '</ul></details>' : '') +
|
||||||
|
(verification ? '<details class="wolai-page-ai-tool-details"><summary><strong>verification</strong><span>' + String(pageAiNormalizeArray(detail.verification).length) + '</span></summary><ul>' + verification + '</ul></details>' : '') +
|
||||||
|
(timeline ? '<details class="wolai-page-ai-tool-details"><summary><strong>timeline</strong><span>' + String(pageAiNormalizeArray(detail.timeline).length) + '</span></summary><ul>' + timeline + '</ul></details>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageAiConversation() {
|
function renderPageAiConversation() {
|
||||||
var drawer = ensurePageAiDrawer();
|
var drawer = ensurePageAiDrawer();
|
||||||
renderPageAiSuggestions();
|
renderPageAiSuggestions();
|
||||||
@@ -1721,6 +1840,9 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
var previousScrollTop = conversation.scrollTop;
|
var previousScrollTop = conversation.scrollTop;
|
||||||
var openToolDetails = pageAiCaptureOpenToolDetails(conversation);
|
var openToolDetails = pageAiCaptureOpenToolDetails(conversation);
|
||||||
conversation.innerHTML = pageAiConversationRenderHtml(pageUiState.pageAiMessages);
|
conversation.innerHTML = pageAiConversationRenderHtml(pageUiState.pageAiMessages);
|
||||||
|
if (pageUiState.pageAiBoardActiveDetailRunId) {
|
||||||
|
conversation.insertAdjacentHTML('beforeend', pageAiBoardRunDetailHtml(pageUiState.pageAiBoardActiveDetailRunId));
|
||||||
|
}
|
||||||
pageAiRestoreOpenToolDetails(conversation, openToolDetails);
|
pageAiRestoreOpenToolDetails(conversation, openToolDetails);
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
conversation.scrollTop = conversation.scrollHeight;
|
conversation.scrollTop = conversation.scrollHeight;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
profile = profileId;
|
profile = profileId;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
schema: String(session && session.schema || '').trim(),
|
||||||
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
||||||
title: String(session && session.title || '').trim() || '新会话',
|
title: String(session && session.title || '').trim() || '新会话',
|
||||||
agentId: agentId,
|
agentId: agentId,
|
||||||
@@ -97,12 +98,18 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
|
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
|
||||||
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
|
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
|
||||||
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
||||||
|
boardRunId: String(session && (session.boardRunId || session.board_run_id || session.runId || session.run_id) || '').trim(),
|
||||||
|
workflowId: String(session && (session.workflowId || session.workflow_id) || '').trim(),
|
||||||
|
workerPresetId: String(session && (session.workerPresetId || session.worker_preset_id) || '').trim(),
|
||||||
|
modelOverride: String(session && (session.modelOverride || session.model_override) || '').trim(),
|
||||||
|
receiptId: String(session && (session.receiptId || session.receipt_id) || '').trim(),
|
||||||
|
boardRuns: session && session.boardRuns && typeof session.boardRuns === 'object' ? session.boardRuns : {},
|
||||||
status: String(session && session.status || '').trim(),
|
status: String(session && session.status || '').trim(),
|
||||||
runtimeMode: String(session && (session.runtimeMode || session.runtime_mode) || '').trim(),
|
runtimeMode: String(session && (session.runtimeMode || session.runtime_mode) || '').trim(),
|
||||||
replaySeen: Boolean(session && session.replaySeen),
|
replaySeen: Boolean(session && session.replaySeen),
|
||||||
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
||||||
preview: String(session && session.preview || '').trim(),
|
preview: String(session && session.preview || '').trim(),
|
||||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
|
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300).map(function(message) { return Object.assign({}, message); }) : []
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort(function(a, b) {
|
.sort(function(a, b) {
|
||||||
@@ -208,6 +215,15 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
var storageVersion = Number(parsed && parsed.version || 0);
|
var storageVersion = Number(parsed && parsed.version || 0);
|
||||||
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
||||||
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
||||||
|
var storedSessions = pageAiNormalizeSessions(parsed && parsed.sessions);
|
||||||
|
if (storageVersion >= sessionStorageVersion && storedSessions.length) {
|
||||||
|
pageUiState.pageAiSessions = storedSessions;
|
||||||
|
var activeSessionId = String(parsed && parsed.activeSessionId || '').trim();
|
||||||
|
pageUiState.pageAiActiveSessionId = storedSessions.some(function(session) { return session.id === activeSessionId; }) ? activeSessionId : storedSessions[0].id;
|
||||||
|
var active = pageAiCurrentSession();
|
||||||
|
pageUiState.pageAiMessages = active && Array.isArray(active.messages) ? active.messages.slice() : [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
var fresh = pageAiNewSession();
|
var fresh = pageAiNewSession();
|
||||||
pageUiState.pageAiSessions = [fresh];
|
pageUiState.pageAiSessions = [fresh];
|
||||||
@@ -443,15 +459,16 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pageAiPersistSessions() {
|
function pageAiPersistSessions() {
|
||||||
|
pageAiSyncCurrentSessionMessages();
|
||||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||||
try {
|
try {
|
||||||
pageAiSyncCurrentSessionMessages();
|
|
||||||
win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||||
version: sessionStorageVersion,
|
version: sessionStorageVersion,
|
||||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||||
activeProfileName: pageAiCurrentProfile(),
|
activeProfileName: pageAiCurrentProfile(),
|
||||||
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'
|
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||||
|
sessions: pageAiNormalizeArray(pageUiState.pageAiSessions).slice(0, 20)
|
||||||
}));
|
}));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -603,6 +620,12 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '';
|
session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '';
|
||||||
session.profile = runProfile;
|
session.profile = runProfile;
|
||||||
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
|
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
|
||||||
|
if (String(session.source || '') === 'board') {
|
||||||
|
session.schema = session.schema || 'mnote.page_ai.session.v2';
|
||||||
|
session.workerPresetId = pageUiState.pageAiBoardWorkerId || session.workerPresetId || '';
|
||||||
|
session.workflowId = pageUiState.pageAiBoardWorkflowId || session.workflowId || '';
|
||||||
|
session.modelOverride = pageUiState.pageAiBoardModelOverride || session.modelOverride || '';
|
||||||
|
}
|
||||||
session.updatedAt = Date.now();
|
session.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +634,14 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
pageUiState.pageAiActiveSessionId = session.id;
|
pageUiState.pageAiActiveSessionId = session.id;
|
||||||
if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId);
|
if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId);
|
||||||
|
if (session.source === 'board') {
|
||||||
|
if (session.workerPresetId) pageUiState.pageAiBoardWorkerId = session.workerPresetId;
|
||||||
|
if (session.workflowId) pageUiState.pageAiBoardWorkflowId = session.workflowId;
|
||||||
|
if (session.modelOverride) pageUiState.pageAiBoardModelOverride = session.modelOverride;
|
||||||
|
if (session.boardRuns && typeof session.boardRuns === 'object') {
|
||||||
|
pageUiState.pageAiBoardRunDetails = Object.assign({}, pageUiState.pageAiBoardRunDetails || {}, session.boardRuns);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime;
|
if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime;
|
||||||
if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile);
|
if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile);
|
||||||
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
|
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="wolai-page-history-panel">' +
|
'<div class="wolai-page-history-panel">' +
|
||||||
'<div class="wolai-page-history-header">' +
|
'<div class="wolai-page-history-header">' +
|
||||||
'<div><div class="wolai-page-history-title">页面历史</div><div class="wolai-page-history-subtitle">当前会话内最近保存的 15 个快照。</div></div>' +
|
'<div><div class="wolai-page-history-title">页面历史</div><div class="wolai-page-history-subtitle">当前会话内最近保存的 15 个快照。</div></div>' +
|
||||||
'<button type="button" class="wolai-surface-close" data-page-history-action="close" aria-label="关闭页面历史">×</button>' +
|
'<button type="button" class="wolai-surface-close" data-page-history-action="close" aria-label="关闭页面历史"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-history-list" data-page-history-list></div>' +
|
'<div class="wolai-page-history-list" data-page-history-list></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -527,7 +527,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="wolai-page-share-card" role="dialog" aria-modal="true">' +
|
'<div class="wolai-page-share-card" role="dialog" aria-modal="true">' +
|
||||||
'<div class="wolai-page-share-header">' +
|
'<div class="wolai-page-share-header">' +
|
||||||
'<div><div class="wolai-page-share-title">公开分享页面</div><div class="wolai-page-share-subtitle">当前 3000 公开入口由 mnote-web 持有。</div></div>' +
|
'<div><div class="wolai-page-share-title">公开分享页面</div><div class="wolai-page-share-subtitle">当前 3000 公开入口由 mnote-web 持有。</div></div>' +
|
||||||
'<button type="button" class="wolai-surface-close" data-page-share-action="close" aria-label="关闭公开分享页面">×</button>' +
|
'<button type="button" class="wolai-surface-close" data-page-share-action="close" aria-label="关闭公开分享页面"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-share-state">' +
|
'<div class="wolai-page-share-state">' +
|
||||||
'<span class="wolai-public-pill wolai-public-pill--inline">全网公开</span>' +
|
'<span class="wolai-public-pill wolai-public-pill--inline">全网公开</span>' +
|
||||||
@@ -658,7 +658,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-local-index-settings-panel" role="dialog" aria-modal="false" aria-label="索引设置">' +
|
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-local-index-settings-panel" role="dialog" aria-modal="false" aria-label="索引设置">' +
|
||||||
'<div class="mnote-settings-panel-head">' +
|
'<div class="mnote-settings-panel-head">' +
|
||||||
'<strong>索引设置</strong>' +
|
'<strong>索引设置</strong>' +
|
||||||
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭索引设置">×</button>' +
|
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭索引设置"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-settings-index-panel" data-testid="wolai-page-settings-local-index-panel">' +
|
'<div class="wolai-page-settings-index-panel" data-testid="wolai-page-settings-local-index-panel">' +
|
||||||
'<div class="wolai-page-settings-index-status" data-testid="wolai-page-settings-local-index-status"></div>' +
|
'<div class="wolai-page-settings-index-status" data-testid="wolai-page-settings-local-index-status"></div>' +
|
||||||
@@ -703,7 +703,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel" role="dialog" aria-modal="false" aria-label="资料库问答设置">' +
|
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel" role="dialog" aria-modal="false" aria-label="资料库问答设置">' +
|
||||||
'<div class="mnote-settings-panel-head">' +
|
'<div class="mnote-settings-panel-head">' +
|
||||||
'<strong>资料库问答</strong>' +
|
'<strong>资料库问答</strong>' +
|
||||||
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭资料库问答设置">×</button>' +
|
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭资料库问答设置"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="资料库设置子面板">' +
|
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="资料库设置子面板">' +
|
||||||
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources">资料源管理</button>' +
|
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources">资料源管理</button>' +
|
||||||
@@ -919,7 +919,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(localIndexStatusLabel(kind)) + '"></span>' +
|
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(localIndexStatusLabel(kind)) + '"></span>' +
|
||||||
'<input type="text" class="wolai-page-settings-index-path" data-local-index-range-input="true" data-local-index-value="' + escapeHtml(normalizedPath) + '" value="' + escapeHtml(displayPath) + '" spellcheck="false"' + (inputDisabled ? ' disabled' : '') + (isPersisted ? ' data-local-index-persisted="true" title="已保存的索引目录不能直接修改;删除后重新新增范围"' : '') + ' />' +
|
'<input type="text" class="wolai-page-settings-index-path" data-local-index-range-input="true" data-local-index-value="' + escapeHtml(normalizedPath) + '" value="' + escapeHtml(displayPath) + '" spellcheck="false"' + (inputDisabled ? ' disabled' : '') + (isPersisted ? ' data-local-index-persisted="true" title="已保存的索引目录不能直接修改;删除后重新新增范围"' : '') + ' />' +
|
||||||
rootScopeHint +
|
rootScopeHint +
|
||||||
'<button type="button" class="wolai-page-settings-index-remove" data-local-index-action="remove-path" data-local-index-path-index="' + String(index) + '"' + (disabled ? ' disabled' : '') + ' aria-label="删除索引范围">×</button>' +
|
'<button type="button" class="wolai-page-settings-index-remove" data-local-index-action="remove-path" data-local-index-path-index="' + String(index) + '"' + (disabled ? ' disabled' : '') + ' aria-label="删除索引范围"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -1132,7 +1132,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="mnote-knowledge-rag-source-input-row" data-knowledge-rag-source-input-row="true">' +
|
'<div class="mnote-knowledge-rag-source-input-row" data-knowledge-rag-source-input-row="true">' +
|
||||||
'<input type="text" class="wolai-page-settings-index-path" data-knowledge-rag-source-input="true" value="' + escapeHtml(value || '') + '" placeholder="文件或目录,例如 docs/book.pdf / docs" spellcheck="false" />' +
|
'<input type="text" class="wolai-page-settings-index-path" data-knowledge-rag-source-input="true" value="' + escapeHtml(value || '') + '" placeholder="文件或目录,例如 docs/book.pdf / docs" spellcheck="false" />' +
|
||||||
'<span class="mnote-knowledge-rag-input-status" data-knowledge-rag-input-status-label="true" hidden></span>' +
|
'<span class="mnote-knowledge-rag-input-status" data-knowledge-rag-input-status-label="true" hidden></span>' +
|
||||||
'<button type="button" class="wolai-page-settings-index-remove" data-knowledge-rag-action="remove-source" aria-label="删除资料来源">×</button>' +
|
'<button type="button" class="wolai-page-settings-index-remove" data-knowledge-rag-action="remove-source" aria-label="删除资料来源"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,10 +99,7 @@ fn mindmap_tools() -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn office_tools() -> Vec<Value> {
|
fn office_tools() -> Vec<Value> {
|
||||||
vec![
|
vec![office_fetch_summary_tool(), office_propose_changes_tool()]
|
||||||
office_fetch_summary_tool(),
|
|
||||||
office_propose_changes_tool(),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn onlyoffice_tools() -> Vec<Value> {
|
fn onlyoffice_tools() -> Vec<Value> {
|
||||||
@@ -160,8 +157,6 @@ fn artifact_tools() -> Vec<Value> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn annotate_tools_with_capabilities(tools: Vec<Value>) -> Vec<Value> {
|
fn annotate_tools_with_capabilities(tools: Vec<Value>) -> Vec<Value> {
|
||||||
tools
|
tools
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ pub(crate) mod evidence;
|
|||||||
mod gateway;
|
mod gateway;
|
||||||
mod health;
|
mod health;
|
||||||
mod hermes;
|
mod hermes;
|
||||||
mod ui_debug;
|
|
||||||
mod hermes_client;
|
mod hermes_client;
|
||||||
mod hermes_tools;
|
mod hermes_tools;
|
||||||
mod kernel;
|
mod kernel;
|
||||||
@@ -25,6 +24,8 @@ mod mindmap_shell;
|
|||||||
pub(crate) mod navigation_recent;
|
pub(crate) mod navigation_recent;
|
||||||
mod onlyoffice;
|
mod onlyoffice;
|
||||||
pub(crate) mod onlyoffice_bridge;
|
pub(crate) mod onlyoffice_bridge;
|
||||||
|
mod page_ai_board;
|
||||||
|
mod page_ai_opencode;
|
||||||
mod page_ai_workflow;
|
mod page_ai_workflow;
|
||||||
mod query_support;
|
mod query_support;
|
||||||
mod resource_trash;
|
mod resource_trash;
|
||||||
@@ -36,6 +37,7 @@ mod sse;
|
|||||||
mod stream_support;
|
mod stream_support;
|
||||||
mod tree;
|
mod tree;
|
||||||
mod tree_view_state;
|
mod tree_view_state;
|
||||||
|
mod ui_debug;
|
||||||
pub(crate) mod ui_preferences;
|
pub(crate) mod ui_preferences;
|
||||||
pub(crate) mod web_shell;
|
pub(crate) mod web_shell;
|
||||||
mod ws;
|
mod ws;
|
||||||
@@ -162,6 +164,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/pdf-preview", get(web_shell::pdf_preview_page))
|
.route("/pdf-preview", get(web_shell::pdf_preview_page))
|
||||||
.route("/api/pdfjs/{*asset_path}", get(web_shell::pdfjs_asset))
|
.route("/api/pdfjs/{*asset_path}", get(web_shell::pdfjs_asset))
|
||||||
|
.route(
|
||||||
|
"/api/mnote-browser-runtime/mnote-ui-runtime.js",
|
||||||
|
get(web_shell::mnote_ui_runtime_asset),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/mnote-browser-runtime/resource-open-runtime.js",
|
"/api/mnote-browser-runtime/resource-open-runtime.js",
|
||||||
get(web_shell::resource_open_runtime_asset),
|
get(web_shell::resource_open_runtime_asset),
|
||||||
@@ -379,6 +385,112 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/ai/agent-profiles",
|
"/api/ai/agent-profiles",
|
||||||
get(hermes_client::list_agent_profiles),
|
get(hermes_client::list_agent_profiles),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/status",
|
||||||
|
get(page_ai_opencode::status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/session",
|
||||||
|
post(page_ai_opencode::bind_session),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/sessions",
|
||||||
|
get(page_ai_opencode::sessions),
|
||||||
|
)
|
||||||
|
.route("/api/page-ai/opencode/abort", post(page_ai_opencode::abort))
|
||||||
|
.route("/api/page-ai/opencode/todo", get(page_ai_opencode::todo))
|
||||||
|
.route("/api/page-ai/opencode/diff", get(page_ai_opencode::diff))
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/messages",
|
||||||
|
get(page_ai_opencode::messages),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/prompt",
|
||||||
|
post(page_ai_opencode::prompt),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/permissions",
|
||||||
|
get(page_ai_opencode::permissions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/permission/reply",
|
||||||
|
post(page_ai_opencode::reply_permission),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/opencode/events",
|
||||||
|
get(page_ai_opencode::events),
|
||||||
|
)
|
||||||
|
.route("/page-ai/opencode", any(page_ai_opencode::proxy_root))
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/assets/{*path}",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/favicon-96x96-v3.png",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/favicon-v3.svg",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/favicon-v3.ico",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/apple-touch-icon-v3.png",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/site.webmanifest",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/page-ai/opencode/social-share.png",
|
||||||
|
any(page_ai_opencode::proxy_assets),
|
||||||
|
)
|
||||||
|
.route("/page-ai/opencode/{*path}", any(page_ai_opencode::proxy))
|
||||||
|
.route("/assets/{*path}", any(page_ai_opencode::proxy_assets))
|
||||||
|
.route("/global/{*path}", any(page_ai_opencode::proxy_assets))
|
||||||
|
.route("/favicon-96x96-v3.png", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/favicon-v3.svg", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/favicon-v3.ico", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/apple-touch-icon-v3.png", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/site.webmanifest", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/social-share.png", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/provider", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/path", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/project", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/project/{*path}", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/lsp", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/command", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/mcp", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/agent", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/config", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/vcs", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/permission", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/question", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/event", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/session", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/session/{*path}", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/new-session", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/{opencode_dir}/session", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/{opencode_dir}/session/{*path}", any(page_ai_opencode::proxy_current_path))
|
||||||
|
.route("/api/page-ai/board/status", get(page_ai_board::status))
|
||||||
|
.route("/api/page-ai/board/workers", get(page_ai_board::workers))
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/board/workflows",
|
||||||
|
get(page_ai_board::workflows),
|
||||||
|
)
|
||||||
|
.route("/api/page-ai/board/runs", post(page_ai_board::create_run))
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/board/runs/{run_id}",
|
||||||
|
get(page_ai_board::get_run),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/board/runs/{run_id}/cancel",
|
||||||
|
post(page_ai_board::cancel_run),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/sidebar/shortcuts",
|
"/api/sidebar/shortcuts",
|
||||||
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
use crate::app::AppState;
|
||||||
|
use crate::context::RequestContext;
|
||||||
|
use crate::error::WebError;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const DEFAULT_BOARD_BASE_URL: &str = "http://127.0.0.1:3901/api";
|
||||||
|
const DEFAULT_BOARD_PROJECT_ID: &str = "51067826-50c7-4869-a8cd-5496f08ca8e6";
|
||||||
|
const DEFAULT_PAGE_AI_WORKFLOW_ID: &str = "builtin-mnote-page-ai-chat";
|
||||||
|
const DEFAULT_PAGE_AI_WORKER_PRESET_ID: &str = "mnote-page-ai-zcode";
|
||||||
|
const DEFAULT_PAGE_AI_MODEL_OVERRIDE: &str = "zcode-default";
|
||||||
|
|
||||||
|
fn page_ai_worker_options() -> Value {
|
||||||
|
json!([{
|
||||||
|
"id": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
|
||||||
|
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
|
||||||
|
"name": "MNote 页面 AI · ZCode",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"role": "developer",
|
||||||
|
"agentType": "zcode",
|
||||||
|
"capabilities": ["text", "repo-edit", "terminal", "mnote-capability-envelope", "local-markdown-edit"],
|
||||||
|
"modelOptions": page_ai_model_options(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_ai_workflow_options() -> Value {
|
||||||
|
json!([{
|
||||||
|
"id": DEFAULT_PAGE_AI_WORKFLOW_ID,
|
||||||
|
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
|
||||||
|
"name": "MNote 页面 AI",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"stages": ["answer"],
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_ai_model_options() -> Value {
|
||||||
|
json!([
|
||||||
|
{ "id": "zcode-default", "label": "默认", "default": true },
|
||||||
|
{ "id": "zcode-fast", "label": "快速" },
|
||||||
|
{ "id": "zcode-strong", "label": "强力" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_page_ai_route(
|
||||||
|
context: &RequestContext,
|
||||||
|
workflow_id: &str,
|
||||||
|
worker_preset_id: &str,
|
||||||
|
model_override: &str,
|
||||||
|
) -> Result<(), WebError> {
|
||||||
|
if workflow_id != DEFAULT_PAGE_AI_WORKFLOW_ID
|
||||||
|
|| worker_preset_id != DEFAULT_PAGE_AI_WORKER_PRESET_ID
|
||||||
|
{
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"page_ai_board_route_not_allowed",
|
||||||
|
"Page AI 只能使用 mnote-page-ai 白名单 worker/workflow",
|
||||||
|
)
|
||||||
|
.with_context(context));
|
||||||
|
}
|
||||||
|
let allowed_models = ["zcode-default", "zcode-fast", "zcode-strong"];
|
||||||
|
if !allowed_models.contains(&model_override) {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"page_ai_board_model_not_allowed",
|
||||||
|
"Page AI 只能使用当前 MNote worker 允许的模型档位",
|
||||||
|
)
|
||||||
|
.with_context(context));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||||
|
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||||
|
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(WebError::new(
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"page_ai_board_unauthorized",
|
||||||
|
"页面 AI Agent Board bridge 需要登录后访问",
|
||||||
|
)
|
||||||
|
.with_context(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn board_base_url() -> String {
|
||||||
|
std::env::var("MNOTE_AGENT_BOARD_API_BASE")
|
||||||
|
.or_else(|_| {
|
||||||
|
std::env::var("MNOTE_AGENT_BOARD_BASE_URL")
|
||||||
|
.map(|value| format!("{}/api", value.trim_end_matches('/')))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| DEFAULT_BOARD_BASE_URL.to_string())
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_project_id() -> String {
|
||||||
|
std::env::var("MNOTE_AGENT_BOARD_PROJECT_ID")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_BOARD_PROJECT_ID.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn board_request(
|
||||||
|
context: &RequestContext,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::internal(format!("Agent Board client 构造失败: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
let url = format!("{}{}", board_base_url(), path);
|
||||||
|
let mut request = client
|
||||||
|
.request(method, &url)
|
||||||
|
.header("accept", "application/json");
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.json(&body);
|
||||||
|
}
|
||||||
|
let response = request.send().await.map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"page_ai_board_unreachable",
|
||||||
|
format!("无法连接 Agent Board: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(WebError::bad_gateway_code(
|
||||||
|
"page_ai_board_error",
|
||||||
|
format!("Agent Board 返回 HTTP {status}: {payload}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_details(payload));
|
||||||
|
}
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
) -> Result<Json<Value>, WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let payload = board_request(&context, Method::GET, "/health", None).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.page_ai_board_status.v1",
|
||||||
|
"baseUrl": board_base_url(),
|
||||||
|
"projectId": default_project_id(),
|
||||||
|
"board": payload,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn workers(
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
) -> Result<Json<Value>, WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let project_id = default_project_id();
|
||||||
|
let payload = board_request(
|
||||||
|
&context,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/workers/catalog?projectId={project_id}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| json!(null));
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "agent_board.page_ai_route.v2",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"projectId": project_id,
|
||||||
|
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
|
||||||
|
"workerName": "MNote 页面 AI · ZCode",
|
||||||
|
"allowedWorkerPresetIds": [DEFAULT_PAGE_AI_WORKER_PRESET_ID],
|
||||||
|
"modelOverride": DEFAULT_PAGE_AI_MODEL_OVERRIDE,
|
||||||
|
"modelOptions": page_ai_model_options(),
|
||||||
|
"workers": page_ai_worker_options(),
|
||||||
|
"boardCatalog": payload,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn workflows(
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
) -> Result<Json<Value>, WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let project_id = default_project_id();
|
||||||
|
let payload = board_request(
|
||||||
|
&context,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/workflow-presets?projectId={project_id}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| json!(null));
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "agent_board.page_ai_route.v2",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"projectId": project_id,
|
||||||
|
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
|
||||||
|
"workflowName": "MNote 页面 AI",
|
||||||
|
"allowedWorkflowIds": [DEFAULT_PAGE_AI_WORKFLOW_ID],
|
||||||
|
"requiresConfirmation": false,
|
||||||
|
"requires": {
|
||||||
|
"filesystem": true,
|
||||||
|
"write": false,
|
||||||
|
"browser": false,
|
||||||
|
"vision": false,
|
||||||
|
},
|
||||||
|
"stages": ["answer"],
|
||||||
|
"workflows": page_ai_workflow_options(),
|
||||||
|
"boardCatalog": payload,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_run(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Json(mut body): Json<Value>,
|
||||||
|
) -> Result<Json<Value>, WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let project_id = body
|
||||||
|
.get("projectId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(default_project_id);
|
||||||
|
let envelope = body.get("envelope").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
let user_message = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("请处理当前页面任务")
|
||||||
|
.trim();
|
||||||
|
let workflow_id = body
|
||||||
|
.get("workflowId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_PAGE_AI_WORKFLOW_ID)
|
||||||
|
.to_string();
|
||||||
|
let worker_preset_id = body
|
||||||
|
.get("workerPresetId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_PAGE_AI_WORKER_PRESET_ID)
|
||||||
|
.to_string();
|
||||||
|
let model_override = body
|
||||||
|
.get("modelOverride")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| envelope.get("modelOverride").and_then(Value::as_str))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_PAGE_AI_MODEL_OVERRIDE)
|
||||||
|
.to_string();
|
||||||
|
validate_page_ai_route(&context, &workflow_id, &worker_preset_id, &model_override)?;
|
||||||
|
let board_message = format!(
|
||||||
|
"你正在处理 MNote Page AI 发来的任务。你的最终回复会直接显示在页面 AI 对话里。\n\n用户请求:\n{user_message}\n\nMNote Page AI envelope:\n```json\n{}\n```\n\n要求:\n1. 只读问答要像普通页面 AI 一样直接回答用户,不要输出 Board 任务报告。\n2. 如果任务要求编辑页面,只修改 envelope.primaryTarget 指向的真实文件,不要调用 MNote 内部页面写入接口。\n3. 你的源头最终回答必须是自然语言 final answer;同时在结构化 receipt.finalAnswer/changedFiles/verification/remaining 中写入运行记录。\n4. 为兼容旧运行器,<task-summary> 可以包含 ## FINAL_ANSWER 段,但不要把 Completed/Comments/Remaining 当作用户主回答。",
|
||||||
|
serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
body["projectId"] = Value::String(project_id.clone());
|
||||||
|
body["message"] = Value::String(board_message);
|
||||||
|
body["envelope"] = envelope;
|
||||||
|
body["surface"] = Value::String("mnote-page-ai".into());
|
||||||
|
body["workflowId"] = Value::String(workflow_id.clone());
|
||||||
|
body["workerPresetId"] = Value::String(worker_preset_id.clone());
|
||||||
|
body["modelOverride"] = Value::String(model_override.clone());
|
||||||
|
if body.get("autoRun").is_none() {
|
||||||
|
body["autoRun"] = Value::Bool(true);
|
||||||
|
}
|
||||||
|
let payload = board_request(&context, Method::POST, "/workflow-runs", Some(body)).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.page_ai_board_run.v1",
|
||||||
|
"surface": "mnote-page-ai",
|
||||||
|
"projectId": project_id,
|
||||||
|
"workflowId": workflow_id,
|
||||||
|
"workerPresetId": worker_preset_id,
|
||||||
|
"modelOverride": model_override,
|
||||||
|
"board": payload,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_run(
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Path(run_id): Path<String>,
|
||||||
|
) -> Result<Json<Value>, WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let details = board_request(
|
||||||
|
&context,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/workflow-runs/{run_id}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let receipt = board_request(
|
||||||
|
&context,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/workflow-runs/{run_id}/receipt"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| json!(null));
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.page_ai_board_run_status.v1",
|
||||||
|
"runId": run_id,
|
||||||
|
"board": details,
|
||||||
|
"receipt": receipt,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_run(
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Path(run_id): Path<String>,
|
||||||
|
) -> Result<Json<Value>, WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let payload = board_request(
|
||||||
|
&context,
|
||||||
|
Method::POST,
|
||||||
|
&format!("/workflow-runs/{run_id}/cancel"),
|
||||||
|
Some(json!({})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(
|
||||||
|
json!({ "ok": true, "runId": run_id, "board": payload }),
|
||||||
|
))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,9 @@ pub async fn components_shell(
|
|||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
) -> Result<Response, WebError> {
|
) -> Result<Response, WebError> {
|
||||||
let workspace_id = "default";
|
let workspace_id = "default";
|
||||||
let sidebar_tree_html =
|
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None)
|
||||||
load_sidebar_tree_html(state.config(), &context, workspace_id, None)
|
.await
|
||||||
.await
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let body_content = crate::ssr::render_view(leptos::view! {
|
let body_content = crate::ssr::render_view(leptos::view! {
|
||||||
<UiDebugComponentsPage
|
<UiDebugComponentsPage
|
||||||
|
|||||||
@@ -2428,6 +2428,20 @@ pub async fn resource_open_runtime_asset() -> Response {
|
|||||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn mnote_ui_runtime_asset() -> Response {
|
||||||
|
const JS: &str = include_str!("../../browser/mnote-ui-runtime.js");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
)
|
||||||
|
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||||
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.body(browser_runtime_js_body(JS))
|
||||||
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn local_upload_runtime_asset() -> Response {
|
pub async fn local_upload_runtime_asset() -> Response {
|
||||||
const JS: &str = include_str!("../../browser/local-upload-runtime.js");
|
const JS: &str = include_str!("../../browser/local-upload-runtime.js");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ pub fn PageLayout(
|
|||||||
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
||||||
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
||||||
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
||||||
|
<script type="module" src={browser_runtime_src("mnote-ui-runtime.js")}></script>
|
||||||
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
|
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
|
||||||
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
|
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
|
||||||
<script type="module" src={browser_runtime_src("filetree-runtime.js")}></script>
|
<script type="module" src={browser_runtime_src("filetree-runtime.js")}></script>
|
||||||
@@ -206,6 +207,7 @@ pub fn PageLayout(
|
|||||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" data-state="closed" aria-haspopup="dialog" aria-expanded="false" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" data-state="closed" aria-haspopup="dialog" aria-expanded="false" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mnote-portal-root" data-mnote-portal-root="true"></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +237,7 @@ mod tests {
|
|||||||
// resume/journal contract checks inspect the actual shipped JS.
|
// resume/journal contract checks inspect the actual shipped JS.
|
||||||
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
||||||
|
const MNOTE_UI_RUNTIME_JS: &str = include_str!("../../../browser/mnote-ui-runtime.js");
|
||||||
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
|
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
|
||||||
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
||||||
@@ -418,6 +421,15 @@ mod tests {
|
|||||||
assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#));
|
assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#));
|
||||||
assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#));
|
assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#));
|
||||||
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
||||||
|
assert!(html.contains(r#"id="mnote-portal-root""#));
|
||||||
|
assert!(html.contains("/api/mnote-browser-runtime/mnote-ui-runtime.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mnote_ui_runtime_exposes_toast_api() {
|
||||||
|
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.toast"));
|
||||||
|
assert!(MNOTE_UI_RUNTIME_JS.contains("data-mnote-toast-region"));
|
||||||
|
assert!(MNOTE_UI_RUNTIME_JS.contains("mnote:toast"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -445,6 +457,10 @@ mod tests {
|
|||||||
html.contains("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js?devHot="),
|
html.contains("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js?devHot="),
|
||||||
"dev:hot 下 local-folder event bus URL 必须带 cache buster,避免复用旧连接编排逻辑"
|
"dev:hot 下 local-folder event bus URL 必须带 cache buster,避免复用旧连接编排逻辑"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("/api/mnote-browser-runtime/mnote-ui-runtime.js?devHot="),
|
||||||
|
"dev:hot 下 UI runtime URL 必须带 cache buster,避免 toast/portal 使用旧版本"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
/// - `styles/base.css`: 基础重置、排版、SVG 图标、滚动条、侧栏/顶栏布局
|
/// - `styles/base.css`: 基础重置、排版、SVG 图标、滚动条、侧栏/顶栏布局
|
||||||
/// - `styles/pages/home.css`, `shells.css`, `auth.css`: 页面级样式
|
/// - `styles/pages/home.css`, `shells.css`, `auth.css`: 页面级样式
|
||||||
/// - `styles/components/main.css`: 核心组件样式(编辑器、侧栏、树、附件等)
|
/// - `styles/components/main.css`: 核心组件样式(编辑器、侧栏、树、附件等)
|
||||||
|
/// - `styles/components/toast.css`: 全局 toast 与 portal 反馈样式
|
||||||
/// - `styles/components/search.css`: 搜索弹窗组件
|
/// - `styles/components/search.css`: 搜索弹窗组件
|
||||||
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
|
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
|
||||||
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
|
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
|
||||||
@@ -29,6 +30,8 @@ pub const MNOTE_CSS: &str = concat!(
|
|||||||
"\n",
|
"\n",
|
||||||
include_str!("styles/components/main.css"),
|
include_str!("styles/components/main.css"),
|
||||||
"\n",
|
"\n",
|
||||||
|
include_str!("styles/components/toast.css"),
|
||||||
|
"\n",
|
||||||
include_str!("styles/components/search.css"),
|
include_str!("styles/components/search.css"),
|
||||||
"\n",
|
"\n",
|
||||||
include_str!("styles/components/page-ai.css"),
|
include_str!("styles/components/page-ai.css"),
|
||||||
@@ -140,6 +143,23 @@ mod tests {
|
|||||||
assert!(MNOTE_CSS.contains(".mnote-tree-context-menu"));
|
assert!(MNOTE_CSS.contains(".mnote-tree-context-menu"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mnote_css_contains_ui_foundation_tokens_and_toast() {
|
||||||
|
assert!(MNOTE_CSS.contains("--mnote-btn-primary-bg"));
|
||||||
|
assert!(MNOTE_CSS.contains("--mnote-font-size-base"));
|
||||||
|
assert!(MNOTE_CSS.contains("--wolai-state-focus-ring"));
|
||||||
|
assert!(MNOTE_CSS.contains(".mnote-toast-region"));
|
||||||
|
assert!(MNOTE_CSS.contains(".mnote-toast--success"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mnote_css_does_not_hide_all_closed_state_triggers() {
|
||||||
|
assert!(!MNOTE_CSS.contains("[data-state=\"closed\"] { display: none"));
|
||||||
|
assert!(!MNOTE_CSS.contains("[data-state=\"open\"] { display: block"));
|
||||||
|
assert!(MNOTE_CSS.contains(".wolai-page-ai-drawer[data-state=\"closed\"]"));
|
||||||
|
assert!(MNOTE_CSS.contains(".wolai-page-settings-popover[data-state=\"closed\"]"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_filetree_icons_use_css_masks_instead_of_text_glyphs() {
|
fn sidebar_filetree_icons_use_css_masks_instead_of_text_glyphs() {
|
||||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-file"));
|
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-file"));
|
||||||
|
|||||||
@@ -819,11 +819,6 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== data-state 通用规则 ===== */
|
|
||||||
/* popover / dropdown / dialog 打开/关闭状态 */
|
|
||||||
[data-state="open"] { display: block; }
|
|
||||||
[data-state="closed"] { display: none !important; }
|
|
||||||
|
|
||||||
/* dialog/modal 状态 */
|
/* dialog/modal 状态 */
|
||||||
.mnote-profile-dialog[data-state="open"],
|
.mnote-profile-dialog[data-state="open"],
|
||||||
.ui-debug-dialog-overlay[data-state="open"],
|
.ui-debug-dialog-overlay[data-state="open"],
|
||||||
|
|||||||
@@ -2214,3 +2214,298 @@ button.wolai-page-ai-history-main span {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-drawer[data-page-ai-opencode-host="true"] .wolai-page-ai-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-chrome {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
background: rgba(247, 247, 245, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(27, 28, 28, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-row strong,
|
||||||
|
.wolai-page-ai-opencode-row code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(27, 28, 28, 0.86);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-badges,
|
||||||
|
.wolai-page-ai-opencode-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-badges span,
|
||||||
|
.wolai-page-ai-opencode-empty,
|
||||||
|
.wolai-page-ai-opencode-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(27, 28, 28, 0.68);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-chip:hover {
|
||||||
|
border-color: rgba(35, 131, 226, 0.32);
|
||||||
|
color: var(--wolai-accent, #2383e2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-chip span {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: rgba(27, 28, 28, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-frame-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-iframe {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-iframe[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-iframe-fallback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: rgba(27, 28, 28, 0.62);
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-iframe-fallback[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-chat {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-messages {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-message {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(247, 247, 245, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-message[data-role="assistant"] {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-message-role {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: rgba(27, 28, 28, 0.52);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-message-body {
|
||||||
|
color: rgba(27, 28, 28, 0.88);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-top: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-composer textarea {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 42px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.14);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-send,
|
||||||
|
.wolai-page-ai-opencode-permission button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #1f6feb;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-permissions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-permission {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(227, 115, 14, 0.24);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 247, 237, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-permission span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(27, 28, 28, 0.62);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-sessions-wrap {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-sessions {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-session-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-session-row[data-active="true"] {
|
||||||
|
border-color: rgba(31, 111, 235, 0.38);
|
||||||
|
background: rgba(31, 111, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-session-row span,
|
||||||
|
.wolai-page-ai-opencode-message-role span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(27, 28, 28, 0.52);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-message-role {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-part {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(250, 250, 249, 0.88);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-part summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-part pre,
|
||||||
|
.wolai-page-ai-opencode-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-tool summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-patch {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-patch button,
|
||||||
|
.wolai-page-ai-opencode-part button {
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-opencode-todo {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
list-style-position: inside;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.mnote-toast-region {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--wolai-spacing-lg);
|
||||||
|
right: var(--wolai-spacing-lg);
|
||||||
|
z-index: var(--wolai-z-toast, 1300);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--wolai-spacing-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-toast {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: min(360px, calc(100vw - 32px));
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--wolai-radius-md, 6px);
|
||||||
|
box-shadow: var(--wolai-shadow-overlay, var(--wolai-shadow-lg));
|
||||||
|
background: var(--wolai-bg);
|
||||||
|
border: 1px solid var(--wolai-border);
|
||||||
|
color: var(--wolai-text-primary);
|
||||||
|
font: var(--mnote-font-weight-medium) var(--mnote-font-size-md) / var(--mnote-line-height-normal) var(--wolai-font-sans);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-toast--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-toast--success {
|
||||||
|
border-color: rgba(34, 165, 89, 0.28);
|
||||||
|
background: #ecfdf3;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-toast--warning {
|
||||||
|
border-color: rgba(217, 119, 6, 0.26);
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-toast--error {
|
||||||
|
border-color: rgba(220, 38, 38, 0.24);
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mnote-toast-region {
|
||||||
|
left: var(--wolai-spacing-md);
|
||||||
|
right: var(--wolai-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-toast {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
--wolai-accent-hover: #1d4ed8;
|
--wolai-accent-hover: #1d4ed8;
|
||||||
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
|
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
|
||||||
--wolai-radius: 4px;
|
--wolai-radius: 4px;
|
||||||
|
--wolai-radius-sm: 4px;
|
||||||
|
--wolai-radius-md: 6px;
|
||||||
|
--wolai-radius-lg: 8px;
|
||||||
|
--wolai-radius-xl: 12px;
|
||||||
|
|
||||||
/* font-size scale */
|
/* font-size scale */
|
||||||
--mnote-font-size-2xs: 10px;
|
--mnote-font-size-2xs: 10px;
|
||||||
@@ -54,7 +58,11 @@
|
|||||||
--wolai-z-popover: 90;
|
--wolai-z-popover: 90;
|
||||||
--wolai-z-dropdown: 120;
|
--wolai-z-dropdown: 120;
|
||||||
--wolai-z-context-menu: 1000;
|
--wolai-z-context-menu: 1000;
|
||||||
|
--wolai-z-dialog-backdrop: 1100;
|
||||||
--wolai-z-modal: 1200;
|
--wolai-z-modal: 1200;
|
||||||
|
--wolai-z-dialog: 1200;
|
||||||
|
--wolai-z-toast: 1300;
|
||||||
|
--wolai-z-tooltip: 1400;
|
||||||
|
|
||||||
/* spacing tokens */
|
/* spacing tokens */
|
||||||
--wolai-spacing-xs: 4px;
|
--wolai-spacing-xs: 4px;
|
||||||
@@ -70,6 +78,7 @@
|
|||||||
--wolai-shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.12);
|
--wolai-shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.12);
|
||||||
--wolai-shadow-popover: 0 4px 16px rgba(15, 23, 42, 0.10);
|
--wolai-shadow-popover: 0 4px 16px rgba(15, 23, 42, 0.10);
|
||||||
--wolai-shadow-modal: 0 18px 54px rgba(15, 23, 42, 0.18);
|
--wolai-shadow-modal: 0 18px 54px rgba(15, 23, 42, 0.18);
|
||||||
|
--wolai-shadow-overlay: 0 18px 48px rgba(15, 23, 42, 0.22), 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
/* state tokens */
|
/* state tokens */
|
||||||
--wolai-state-hover-bg: rgba(55, 53, 47, 0.06);
|
--wolai-state-hover-bg: rgba(55, 53, 47, 0.06);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
radix-leptos-primitives = "0.9.0"
|
|
||||||
leptos = { version = "0.8.19", features = ["csr"] }
|
leptos = { version = "0.8.19", features = ["csr"] }
|
||||||
leptos_dom = "0.8.8"
|
leptos_dom = "0.8.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -25,7 +24,7 @@ send_wrapper = "0.6"
|
|||||||
js-sys = "0.3.77"
|
js-sys = "0.3.77"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MessageEvent", "MouseEvent", "Node", "Range", "RequestInit", "RequestMode", "Response", "Selection", "Storage", "Window"] }
|
web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "KeyboardEvent", "MessageEvent", "MouseEvent", "Node", "NodeList", "Range", "RequestInit", "RequestMode", "Response", "Selection", "Storage", "Window"] }
|
||||||
|
|
||||||
[dependencies.leptos-tiptap]
|
[dependencies.leptos-tiptap]
|
||||||
path = "../../../reference-code/leptos-tiptap"
|
path = "../../../reference-code/leptos-tiptap"
|
||||||
|
|||||||
+20
-20
@@ -48,33 +48,33 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
|
|||||||
|
|
||||||
export interface InitOutput {
|
export interface InitOutput {
|
||||||
readonly memory: WebAssembly.Memory;
|
readonly memory: WebAssembly.Memory;
|
||||||
readonly mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
|
||||||
readonly unmount_mindmap_shell: (a: number) => [number, number];
|
|
||||||
readonly mount: (a: any, b: any) => [number, number, number];
|
readonly mount: (a: any, b: any) => [number, number, number];
|
||||||
|
readonly mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
||||||
readonly unmount: (a: number) => [number, number];
|
readonly unmount: (a: number) => [number, number];
|
||||||
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
readonly unmount_mindmap_shell: (a: number) => [number, number];
|
||||||
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
|
||||||
readonly intounderlyingsink_close: (a: number) => any;
|
|
||||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
|
||||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||||
readonly intounderlyingbytesource_type: (a: number) => number;
|
|
||||||
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||||
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
|
||||||
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
|
||||||
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||||
|
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||||
|
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||||
|
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||||
|
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||||
|
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||||
|
readonly intounderlyingsink_close: (a: number) => any;
|
||||||
|
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||||
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||||
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
|
||||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h854f4676fa692669: (a: number, b: number, c: any) => [number, number];
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350: (a: number, b: number, c: any, d: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf: (a: number, b: number, c: any) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403: (a: number, b: number, c: any) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395: (a: number, b: number) => void;
|
||||||
readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
readonly wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce: (a: number, b: number) => void;
|
||||||
|
readonly wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252: (a: number, b: number) => void;
|
||||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
readonly __externref_table_alloc: () => number;
|
readonly __externref_table_alloc: () => number;
|
||||||
|
|||||||
+37
-37
@@ -812,7 +812,7 @@ function __wbg_get_imports() {
|
|||||||
const a = state0.a;
|
const a = state0.a;
|
||||||
state0.a = 0;
|
state0.a = 0;
|
||||||
try {
|
try {
|
||||||
return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1);
|
return wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(a, state0.b, arg0, arg1);
|
||||||
} finally {
|
} finally {
|
||||||
state0.a = a;
|
state0.a = a;
|
||||||
}
|
}
|
||||||
@@ -1232,48 +1232,48 @@ function __wbg_get_imports() {
|
|||||||
}
|
}
|
||||||
}, arguments); },
|
}, arguments); },
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1011, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
|
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1192, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 969, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1202, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h854f4676fa692669);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1090, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1110, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 637, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 563, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1092, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1108, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1107, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1125, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5);
|
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1143, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1143, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000a: function(arg0) {
|
__wbindgen_cast_000000000000000a: function(arg0) {
|
||||||
@@ -1316,47 +1316,47 @@ function __wbg_get_imports() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h854f4676fa692669(arg0, arg1, arg2) {
|
||||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2);
|
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h854f4676fa692669(arg0, arg1, arg2);
|
||||||
if (ret[1]) {
|
if (ret[1]) {
|
||||||
throw takeFromExternrefTable0(ret[0]);
|
throw takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) {
|
function wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(arg0, arg1, arg2, arg3) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3);
|
wasm.wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(arg0, arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
+20
-20
@@ -1,33 +1,33 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const memory: WebAssembly.Memory;
|
export const memory: WebAssembly.Memory;
|
||||||
export const mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
|
||||||
export const unmount_mindmap_shell: (a: number) => [number, number];
|
|
||||||
export const mount: (a: any, b: any) => [number, number, number];
|
export const mount: (a: any, b: any) => [number, number, number];
|
||||||
|
export const mount_mindmap_shell: (a: any, b: any) => [number, number, number];
|
||||||
export const unmount: (a: number) => [number, number];
|
export const unmount: (a: number) => [number, number];
|
||||||
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
export const unmount_mindmap_shell: (a: number) => [number, number];
|
||||||
export const intounderlyingsink_write: (a: number, b: any) => any;
|
|
||||||
export const intounderlyingsink_close: (a: number) => any;
|
|
||||||
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
|
||||||
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||||
export const intounderlyingbytesource_type: (a: number) => number;
|
|
||||||
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||||
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
|
||||||
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
|
||||||
export const intounderlyingbytesource_cancel: (a: number) => void;
|
export const intounderlyingbytesource_cancel: (a: number) => void;
|
||||||
|
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||||
|
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||||
|
export const intounderlyingbytesource_type: (a: number) => number;
|
||||||
|
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||||
|
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
||||||
|
export const intounderlyingsink_close: (a: number) => any;
|
||||||
|
export const intounderlyingsink_write: (a: number, b: any) => any;
|
||||||
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||||
export const intounderlyingsource_pull: (a: number, b: any) => any;
|
|
||||||
export const intounderlyingsource_cancel: (a: number) => void;
|
export const intounderlyingsource_cancel: (a: number) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
export const intounderlyingsource_pull: (a: number, b: any) => any;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h854f4676fa692669: (a: number, b: number, c: any) => [number, number];
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350: (a: number, b: number, c: any, d: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf: (a: number, b: number, c: any) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
export const wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403: (a: number, b: number, c: any) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
export const wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395: (a: number, b: number) => void;
|
||||||
export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
export const wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce: (a: number, b: number) => void;
|
||||||
|
export const wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252: (a: number, b: number) => void;
|
||||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
export const __externref_table_alloc: () => number;
|
export const __externref_table_alloc: () => number;
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ impl EditorButtonProps {
|
|||||||
/// - data-testid
|
/// - data-testid
|
||||||
/// - tooltip(data-tooltip)
|
/// - tooltip(data-tooltip)
|
||||||
/// - onClick 阻止默认并调用回调
|
/// - onClick 阻止默认并调用回调
|
||||||
#[component]
|
|
||||||
pub(crate) fn EditorButton(props: EditorButtonProps) -> impl IntoView {
|
pub(crate) fn EditorButton(props: EditorButtonProps) -> impl IntoView {
|
||||||
let on_click = props.on_click.clone();
|
let on_click = props.on_click.clone();
|
||||||
let click_handler = move |event: ev::MouseEvent| {
|
let click_handler = move |event: ev::MouseEvent| {
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
//! 这些函数替代原 lib.rs / overlays.rs / view 中的手写 Escape keydown 和
|
//! 这些函数替代原 lib.rs / overlays.rs / view 中的手写 Escape keydown 和
|
||||||
//! document mousedown listener,对齐 Radix DismissableLayer + FocusScope 语义。
|
//! document mousedown listener,对齐 Radix DismissableLayer + FocusScope 语义。
|
||||||
|
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
use web_sys::{
|
use web_sys::{window, Element, HtmlElement, KeyboardEvent, MouseEvent};
|
||||||
window, Document, Element, EventTarget, HtmlElement, KeyboardEvent, MouseEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::editor_runtime::dom_events::target_element;
|
use crate::editor_runtime::dom_events::target_element;
|
||||||
|
|
||||||
@@ -24,18 +23,20 @@ pub(crate) fn register_dismissable_layer(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
on_dismiss: Box<dyn FnMut()>,
|
on_dismiss: Box<dyn FnMut()>,
|
||||||
) -> DismissHandle {
|
) -> DismissHandle {
|
||||||
let dismiss = std::cell::RefCell::new(on_dismiss);
|
let dismiss = Rc::new(RefCell::new(on_dismiss));
|
||||||
let win = window();
|
let win = window();
|
||||||
let doc = win.as_ref().and_then(|w| w.document());
|
let doc = win.as_ref().and_then(|w| w.document());
|
||||||
|
|
||||||
// Escape keydown(捕获阶段,确保在 Tiptap 之前处理)
|
// Escape keydown(捕获阶段,确保在 Tiptap 之前处理)
|
||||||
let container_id_esc = container_id.to_string();
|
let container_id_esc = container_id.to_string();
|
||||||
|
let doc_esc = doc.clone();
|
||||||
|
let dismiss_for_esc = Rc::clone(&dismiss);
|
||||||
let dismiss_esc = Closure::wrap(Box::new(move |event: KeyboardEvent| {
|
let dismiss_esc = Closure::wrap(Box::new(move |event: KeyboardEvent| {
|
||||||
if event.key() != "Escape" {
|
if event.key() != "Escape" {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 只处理 container 可见的情况
|
// 只处理 container 可见的情况
|
||||||
if let Some(ref doc) = doc {
|
if let Some(ref doc) = doc_esc {
|
||||||
if let Ok(Some(el)) = doc.query_selector(&format!("#{container_id_esc}")) {
|
if let Ok(Some(el)) = doc.query_selector(&format!("#{container_id_esc}")) {
|
||||||
if !is_element_visible(&el) {
|
if !is_element_visible(&el) {
|
||||||
return;
|
return;
|
||||||
@@ -44,7 +45,7 @@ pub(crate) fn register_dismissable_layer(
|
|||||||
event.stop_propagation();
|
event.stop_propagation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(mut f) = dismiss.try_borrow_mut() {
|
if let Ok(mut f) = dismiss_for_esc.try_borrow_mut() {
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(_)>);
|
}) as Box<dyn FnMut(_)>);
|
||||||
@@ -59,6 +60,7 @@ pub(crate) fn register_dismissable_layer(
|
|||||||
|
|
||||||
// 点击外部 handler(mousedown,因为 click 可能在 focus 转移之后才触发)
|
// 点击外部 handler(mousedown,因为 click 可能在 focus 转移之后才触发)
|
||||||
let container_id_click = container_id.to_string();
|
let container_id_click = container_id.to_string();
|
||||||
|
let dismiss_for_click = Rc::clone(&dismiss);
|
||||||
let dismiss_click = Closure::wrap(Box::new(move |event: MouseEvent| {
|
let dismiss_click = Closure::wrap(Box::new(move |event: MouseEvent| {
|
||||||
let target = event.target();
|
let target = event.target();
|
||||||
let Some(target_el) = target.and_then(target_element) else {
|
let Some(target_el) = target.and_then(target_element) else {
|
||||||
@@ -72,7 +74,7 @@ pub(crate) fn register_dismissable_layer(
|
|||||||
if let Ok(Some(_)) = target_el.closest(".ProseMirror") {
|
if let Ok(Some(_)) = target_el.closest(".ProseMirror") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(mut f) = dismiss.try_borrow_mut() {
|
if let Ok(mut f) = dismiss_for_click.try_borrow_mut() {
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(_)>);
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub(crate) const ICON_MAP: &[(&str, &str)] = &[
|
|||||||
("autorenew", "autorenew"),
|
("autorenew", "autorenew"),
|
||||||
("link", "link"),
|
("link", "link"),
|
||||||
("arrow_outward", "arrow_outward"),
|
("arrow_outward", "arrow_outward"),
|
||||||
|
("arrow_drop_down", "arrow_drop_down"),
|
||||||
("history", "history"),
|
("history", "history"),
|
||||||
("chat", "chat"),
|
("chat", "chat"),
|
||||||
("palette", "palette"),
|
("palette", "palette"),
|
||||||
@@ -76,7 +77,7 @@ pub(crate) fn icon_ligature(name: &str) -> Option<&'static str> {
|
|||||||
///
|
///
|
||||||
/// The `name` parameter must be a Material Symbols ligature name
|
/// The `name` parameter must be a Material Symbols ligature name
|
||||||
/// (e.g. `"auto_awesome"`, `"chevron_right"`, `"looks_one"`).
|
/// (e.g. `"auto_awesome"`, `"chevron_right"`, `"looks_one"`).
|
||||||
pub(crate) fn material_icon(name: &str) -> impl IntoView {
|
pub(crate) fn material_icon(name: &'static str) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<span class="material-symbols-outlined" aria-hidden="true">{name}</span>
|
<span class="material-symbols-outlined" aria-hidden="true">{name}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
|||||||
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
|
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
|
||||||
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
||||||
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
|
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
|
||||||
- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`
|
- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;opencode WebUI embed 改动补跑 `task763-page-ai-opencode-embed-smoke.js`,只验证真实登录、Page AI 抽屉、opencode iframe/host chrome 和 changed file/open/refresh DOM hook,不 mock 聊天成功;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`
|
||||||
- Dev hot / OnlyOffice live bridge:Sidebar dev hot reload 入口改动补跑 `task514-sidebar-dev-hot-reload-gating-smoke.js`;OnlyOffice live bridge session / scope / HTTP 工具边界改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`;bridge session/token/queue/current/close 和 `docKey/pageOrigin` 元数据改动补跑 `task516-onlyoffice-bridge-multisession-browser-smoke.js`;bridge plugin index / `Asc.plugin` direct loop / plugin 元数据透传改动补跑 `task517-onlyoffice-bridge-plugin-direct-smoke.js`;真实 ONLYOFFICE iframe / DocumentServer session、同文档双 tab、resource scope 和非 dry-run 写入落点改动补跑 `task518-onlyoffice-real-iframe-session-scope-smoke.js`;Page AI 真实 UI 选择 Office target 并冻结 live bridge session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`
|
- Dev hot / OnlyOffice live bridge:Sidebar dev hot reload 入口改动补跑 `task514-sidebar-dev-hot-reload-gating-smoke.js`;OnlyOffice live bridge session / scope / HTTP 工具边界改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`;bridge session/token/queue/current/close 和 `docKey/pageOrigin` 元数据改动补跑 `task516-onlyoffice-bridge-multisession-browser-smoke.js`;bridge plugin index / `Asc.plugin` direct loop / plugin 元数据透传改动补跑 `task517-onlyoffice-bridge-plugin-direct-smoke.js`;真实 ONLYOFFICE iframe / DocumentServer session、同文档双 tab、resource scope 和非 dry-run 写入落点改动补跑 `task518-onlyoffice-real-iframe-session-scope-smoke.js`;Page AI 真实 UI 选择 Office target 并冻结 live bridge session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`
|
||||||
- local resource lifecycle API:`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
|
- local resource lifecycle API:`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||||
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
||||||
|
* - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve
|
||||||
|
* - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode
|
||||||
|
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
|
||||||
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ function resolveBackendExecutable(envName, fallbackName) {
|
|||||||
|
|
||||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||||
|
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
|
||||||
|
|
||||||
function hasCommand(command) {
|
function hasCommand(command) {
|
||||||
try {
|
try {
|
||||||
@@ -68,6 +72,10 @@ function buildDefaultBackendCommand(port) {
|
|||||||
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
|
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDefaultOpencodeCommand(port) {
|
||||||
|
return `while true; do script -qfec "opencode serve --hostname=127.0.0.1 --port ${port} --print-logs" /dev/null; sleep 1; done`;
|
||||||
|
}
|
||||||
|
|
||||||
function isEnabledEnv(value) {
|
function isEnabledEnv(value) {
|
||||||
const normalized = String(value || "").toLowerCase();
|
const normalized = String(value || "").toLowerCase();
|
||||||
return normalized === "1" || normalized === "true";
|
return normalized === "1" || normalized === "true";
|
||||||
@@ -79,6 +87,12 @@ function shouldStartBackend(env = process.env) {
|
|||||||
return isEnabledEnv(env.ENABLE_BACKEND);
|
return isEnabledEnv(env.ENABLE_BACKEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldStartOpencode(env = process.env) {
|
||||||
|
if (isEnabledEnv(env.SKIP_OPENCODE)) return false;
|
||||||
|
if (String(env.OPENCODE_CMD || "").trim()) return true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRuntimePlan(env = process.env) {
|
function resolveRuntimePlan(env = process.env) {
|
||||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||||
const skipGateway = false;
|
const skipGateway = false;
|
||||||
@@ -92,6 +106,7 @@ function resolveRuntimePlan(env = process.env) {
|
|||||||
mnoteWebEnv: {
|
mnoteWebEnv: {
|
||||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||||
|
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,6 +137,17 @@ const tasks = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(shouldStartOpencode(process.env)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "opencode",
|
||||||
|
command:
|
||||||
|
process.env.OPENCODE_CMD ||
|
||||||
|
buildDefaultOpencodeCommand(opencodePortFromEnv),
|
||||||
|
cwd: rootDir,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function findTask(name) {
|
function findTask(name) {
|
||||||
@@ -475,6 +501,24 @@ async function main() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const desiredOpencodePort = opencodePortFromEnv;
|
||||||
|
if (shouldStartOpencode(process.env) && !process.env.OPENCODE_CMD) {
|
||||||
|
const opencodePortOk = await ensurePortFree(desiredOpencodePort, "opencode");
|
||||||
|
if (!opencodePortOk) {
|
||||||
|
console.error(`opencode 端口 ${desiredOpencodePort} 无法释放,已中止启动。`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const opencodeTask = findTask("opencode");
|
||||||
|
if (!opencodeTask) {
|
||||||
|
throw new Error("缺少 opencode 任务配置");
|
||||||
|
}
|
||||||
|
opencodeTask.command = buildDefaultOpencodeCommand(desiredOpencodePort);
|
||||||
|
} else if (isEnabledEnv(process.env.SKIP_OPENCODE)) {
|
||||||
|
logPrefix("opencode", "已跳过 opencode(SKIP_OPENCODE=1)。");
|
||||||
|
} else if (!shouldStartOpencode(process.env)) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
|||||||
assert.deepEqual(plan.mnoteWebEnv, {
|
assert.deepEqual(plan.mnoteWebEnv, {
|
||||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||||
|
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ async function saveScreenshot(page, name) {
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertMaterialIconSurface(locator, label, minCount) {
|
||||||
|
const iconCount = await locator.locator('.material-symbols-outlined, .slash-item-icon, .block-drag-menu-icon').count();
|
||||||
|
if (iconCount < minCount) {
|
||||||
|
throw new Error(`${label} 至少应渲染 ${minCount} 个图标容器,实际: ${iconCount}`);
|
||||||
|
}
|
||||||
|
const text = ((await locator.textContent()) || '').trim();
|
||||||
|
const legacyGlyphs = ['⚙', '×', '✕', '⋮', '◣', '↻'];
|
||||||
|
const leaked = legacyGlyphs.filter((glyph) => text.includes(glyph));
|
||||||
|
if (leaked.length > 0) {
|
||||||
|
throw new Error(`${label} 不应泄漏旧 Unicode 图标: ${leaked.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForRuntimeIsland(page, uiTimeoutMs) {
|
async function waitForRuntimeIsland(page, uiTimeoutMs) {
|
||||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||||
await root.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
await root.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
@@ -110,7 +123,12 @@ async function setBlockHandleFixture(page, uiTimeoutMs) {
|
|||||||
async function closePageAiIfOpen(page) {
|
async function closePageAiIfOpen(page) {
|
||||||
const closeButton = page.locator('[data-page-ai-action="close"]').first();
|
const closeButton = page.locator('[data-page-ai-action="close"]').first();
|
||||||
if (await closeButton.isVisible().catch(() => false)) {
|
if (await closeButton.isVisible().catch(() => false)) {
|
||||||
await closeButton.click().catch(() => undefined);
|
await closeButton.click({ force: true }).catch(async () => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const button = document.querySelector('[data-page-ai-action="close"]');
|
||||||
|
if (button instanceof HTMLElement) button.click();
|
||||||
|
}).catch(() => undefined);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +252,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
|
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, UI_TIMEOUT_MS * 2));
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
@@ -309,11 +328,11 @@ async function main() {
|
|||||||
null,
|
null,
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
const stopButton = page.locator('[data-page-ai-action="stop-run"]').first();
|
const stopButton = page.locator('.wolai-page-ai-stop[data-page-ai-action="stop-run"]').first();
|
||||||
await stopButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await stopButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
const stop = document.querySelector('[data-page-ai-action="stop-run"]');
|
const stop = document.querySelector('.wolai-page-ai-stop[data-page-ai-action="stop-run"]');
|
||||||
return stop instanceof HTMLButtonElement && stop.disabled === false && stop.getAttribute("aria-disabled") === "false";
|
return stop instanceof HTMLButtonElement && stop.disabled === false && stop.getAttribute("aria-disabled") === "false";
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -343,6 +362,7 @@ async function main() {
|
|||||||
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const slashText = (await slashMenu.textContent()) || "";
|
const slashText = (await slashMenu.textContent()) || "";
|
||||||
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
|
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
|
||||||
|
await assertMaterialIconSurface(slashMenu, "slash menu", 4);
|
||||||
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
|
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
@@ -358,6 +378,7 @@ async function main() {
|
|||||||
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const blockMenuText = (await blockMenu.textContent()) || "";
|
const blockMenuText = (await blockMenu.textContent()) || "";
|
||||||
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
|
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
|
||||||
|
await assertMaterialIconSurface(blockMenu, "block handle menu", 8);
|
||||||
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
|
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
const { chromium } = require("playwright");
|
const { chromium } = require("playwright");
|
||||||
const { BASE_URL, UI_TIMEOUT_MS, assert, ensureAuthenticated } = require("./tree-shell-smoke-helpers");
|
const { BASE_URL, UI_TIMEOUT_MS, assert, ensureAuthenticated } = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
@@ -12,9 +14,11 @@ if (MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES !== "1") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_URL = `${BASE_URL}/ui-debug/components`;
|
const PAGE_URL = `${BASE_URL}/ui-debug/components`;
|
||||||
|
const OUT_DIR = process.env.MNOTE_UI_COMPONENTS_OUTPUT_DIR || path.resolve(__dirname, "..", "tmp", "ui-components");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("[task500] 启动 UI Debug 组件矩阵 smoke …");
|
console.log("[task500] 启动 UI Debug 组件矩阵 smoke …");
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -131,12 +135,17 @@ async function main() {
|
|||||||
assert(cssVar.length > 0, `--ui-debug-primary 应有值,实际: "${cssVar}"`);
|
assert(cssVar.length > 0, `--ui-debug-primary 应有值,实际: "${cssVar}"`);
|
||||||
console.log("[task500] ✓ CSS 变量 --ui-debug-primary: %s", cssVar);
|
console.log("[task500] ✓ CSS 变量 --ui-debug-primary: %s", cssVar);
|
||||||
|
|
||||||
|
const screenshotPath = path.join(OUT_DIR, "ui-debug-components.png");
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
|
console.log("[task500] ✓ 截图输出: %s", screenshotPath);
|
||||||
|
|
||||||
console.log("[task500] ✅ 全部断言通过");
|
console.log("[task500] ✅ 全部断言通过");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[task500] ✗ 失败:", error.message);
|
console.error("[task500] ✗ 失败:", error.message);
|
||||||
try {
|
try {
|
||||||
await page.screenshot({ path: "tmp/task500-failure.png", fullPage: true });
|
const failurePath = path.join(OUT_DIR, "task500-failure.png");
|
||||||
console.log("[task500] 失败截图: tmp/task500-failure.png");
|
await page.screenshot({ path: failurePath, fullPage: true });
|
||||||
|
console.log("[task500] 失败截图: %s", failurePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
#!/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,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
ensureAuthenticated,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const TASK = "task762-page-ai-board-first-smoke";
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||||
|
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||||
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||||
|
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
function fileUrl(localPath) {
|
||||||
|
return `file://${localPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMdDocumentId(relativePath) {
|
||||||
|
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||||
|
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, ".mnote", "workspace.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
ownerId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentUrl(root, workspaceId, relativePath) {
|
||||||
|
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||||
|
url.searchParams.set("sourceKind", "local_folder");
|
||||||
|
url.searchParams.set("rootUri", fileUrl(root));
|
||||||
|
url.searchParams.set("workspaceId", workspaceId);
|
||||||
|
url.searchParams.set("treeView", "filetree");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForEditorText(page, expected) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(text) => {
|
||||||
|
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
|
||||||
|
return (editor?.textContent || "").includes(text);
|
||||||
|
},
|
||||||
|
expected,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScreenshot(page, name) {
|
||||||
|
const target = path.join(OUTPUT_DIR, `${name}.png`);
|
||||||
|
await page.screenshot({ path: target, fullPage: true });
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
const actorId = "mnote-e2e";
|
||||||
|
const workspaceId = `local-ws:${actorId}:task762-${suffix}`;
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task762-board-ai-"));
|
||||||
|
const rootUri = fileUrl(root);
|
||||||
|
const relativePath = "BoardFirstPage.md";
|
||||||
|
const documentId = localMdDocumentId(relativePath);
|
||||||
|
const filePath = path.join(root, relativePath);
|
||||||
|
const beforeToken = `BOARD_UI_EDIT_BEFORE_${suffix}`;
|
||||||
|
const afterToken = `BOARD_UI_EDIT_AFTER_${suffix}`;
|
||||||
|
const capturedBoardRuns = [];
|
||||||
|
const capturedBoardRunResponses = [];
|
||||||
|
const consoleErrors = [];
|
||||||
|
let caughtError = null;
|
||||||
|
|
||||||
|
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
["# Board First Page AI", "", `页面段落:${beforeToken}`, ""].join("\n"),
|
||||||
|
"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: 1440, height: 960 },
|
||||||
|
locale: "zh-CN",
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
"x-mnote-actor-id": actorId,
|
||||||
|
"x-mnote-actor-type": "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (["error", "warning"].includes(message.type())) {
|
||||||
|
consoleErrors.push({ type: message.type(), text: message.text() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (!request.url().includes("/api/page-ai/board/runs") || request.method() !== "POST") return;
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(request.postData() || "{}");
|
||||||
|
} catch {
|
||||||
|
body = request.postData() || "";
|
||||||
|
}
|
||||||
|
capturedBoardRuns.push({ url: request.url(), method: request.method(), body });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/page-ai/board/workers", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
schema: "agent_board.page_ai_route.v2",
|
||||||
|
surface: "mnote-page-ai",
|
||||||
|
workerPresetId: "mnote-page-ai-zcode",
|
||||||
|
workerName: "MNote 页面 AI · ZCode",
|
||||||
|
allowedWorkerPresetIds: ["mnote-page-ai-zcode"],
|
||||||
|
modelOverride: "zcode-default",
|
||||||
|
modelOptions: [
|
||||||
|
{ id: "zcode-default", label: "默认", default: true },
|
||||||
|
{ id: "zcode-fast", label: "快速" },
|
||||||
|
{ id: "zcode-strong", label: "强力" },
|
||||||
|
],
|
||||||
|
workers: [{
|
||||||
|
id: "mnote-page-ai-zcode",
|
||||||
|
name: "MNote 页面 AI · ZCode",
|
||||||
|
surface: "mnote-page-ai",
|
||||||
|
agentType: "zcode",
|
||||||
|
modelOptions: [
|
||||||
|
{ id: "zcode-default", label: "默认", default: true },
|
||||||
|
{ id: "zcode-fast", label: "快速" },
|
||||||
|
{ id: "zcode-strong", label: "强力" },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
id: "qa-browser-worker",
|
||||||
|
name: "QA Browser Worker",
|
||||||
|
surface: "agent-board-console",
|
||||||
|
role: "qa",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/page-ai/board/workflows", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
schema: "agent_board.page_ai_route.v2",
|
||||||
|
surface: "mnote-page-ai",
|
||||||
|
workflowId: "builtin-mnote-page-ai-chat",
|
||||||
|
workflowName: "MNote 页面 AI",
|
||||||
|
allowedWorkflowIds: ["builtin-mnote-page-ai-chat"],
|
||||||
|
workflows: [{
|
||||||
|
id: "builtin-mnote-page-ai-chat",
|
||||||
|
name: "MNote 页面 AI",
|
||||||
|
surface: "mnote-page-ai",
|
||||||
|
}, {
|
||||||
|
id: "general-hotfix-workflow",
|
||||||
|
name: "通用 hotfix workflow",
|
||||||
|
surface: "agent-board-console",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/page-ai/board/runs", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") return route.continue();
|
||||||
|
const body = JSON.parse(route.request().postData() || "{}");
|
||||||
|
capturedBoardRunResponses.push({ kind: "create", body });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
schema: "mnote.page_ai_board_run.v1",
|
||||||
|
surface: "mnote-page-ai",
|
||||||
|
runId: `board-run-${suffix}`,
|
||||||
|
workflowId: body.workflowId,
|
||||||
|
workerPresetId: body.workerPresetId,
|
||||||
|
modelOverride: body.modelOverride,
|
||||||
|
board: { run: { id: `board-run-${suffix}`, status: "running" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(`**/api/page-ai/board/runs/board-run-${suffix}`, async (route) => {
|
||||||
|
capturedBoardRunResponses.push({ kind: "get" });
|
||||||
|
fs.writeFileSync(filePath, ["# Board First Page AI", "", `页面段落:${afterToken}`, ""].join("\n"), "utf8");
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
schema: "mnote.page_ai_board_run_status.v1",
|
||||||
|
runId: `board-run-${suffix}`,
|
||||||
|
board: {
|
||||||
|
run: { id: `board-run-${suffix}`, status: "complete" },
|
||||||
|
events: [
|
||||||
|
{ type: "workflow.started", message: "run started", createdAt: new Date().toISOString() },
|
||||||
|
{ type: "worker.completed", message: "file edited", createdAt: new Date().toISOString() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
receipt: {
|
||||||
|
schema: "agent_board.workflow_run_receipt.v2",
|
||||||
|
runId: `board-run-${suffix}`,
|
||||||
|
surface: "mnote-page-ai",
|
||||||
|
workflowId: "builtin-mnote-page-ai-chat",
|
||||||
|
workerPresetId: "mnote-page-ai-zcode",
|
||||||
|
modelOverride: "zcode-fast",
|
||||||
|
finalAnswer: `已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`,
|
||||||
|
changedFiles: [{ path: filePath, status: "modified" }],
|
||||||
|
verification: [{ command: "read file", status: "passed", output: afterToken }],
|
||||||
|
remaining: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.route("**/api/user/access-policy**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
controlPlane: "sqlite",
|
||||||
|
grants: [{
|
||||||
|
id: `grant_task762_${suffix}`,
|
||||||
|
userId: actorId,
|
||||||
|
workspaceId,
|
||||||
|
rootUri,
|
||||||
|
rootPath: root,
|
||||||
|
permission: "write",
|
||||||
|
recursive: true,
|
||||||
|
capabilities: ["ai", "markdown_edit"],
|
||||||
|
source: "smoke",
|
||||||
|
status: "active",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/ui/preferences**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureAuthenticated(page, context.request);
|
||||||
|
const response = await page.goto(documentUrl(root, workspaceId, relativePath), {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
|
||||||
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await waitForEditorText(page, beforeToken);
|
||||||
|
await page.evaluate(() => localStorage.removeItem("mnote.page_ai.legacy_provider_mode"));
|
||||||
|
|
||||||
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||||
|
return drawerText.includes("MNote 页面 AI · ZCode") && !drawerText.includes("QA Browser Worker") && !drawerText.includes("通用 hotfix workflow");
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const modelSelect = page.locator("[data-page-ai-board-model]");
|
||||||
|
await modelSelect.selectOption("zcode-fast", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
|
await page.locator("[data-page-ai-input]").fill(
|
||||||
|
`请编辑当前页面真实 Markdown 文件,把 ${beforeToken} 替换为 ${afterToken}。完成后说明 MNote capability 已加载。`,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => {
|
||||||
|
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||||
|
return text.includes("正在处理") || text.includes(expected);
|
||||||
|
},
|
||||||
|
afterToken,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||||
|
const subtitle = drawer?.querySelector('.wolai-page-ai-subtitle')?.textContent || "";
|
||||||
|
const agentChip = drawer?.querySelector('[data-page-ai-agent-chip]')?.textContent || "";
|
||||||
|
const hiddenLegacyTabs = Array.from(drawer?.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]') || [])
|
||||||
|
.every((node) => node instanceof HTMLElement && node.hidden === true);
|
||||||
|
return subtitle.includes("Agent Board") && agentChip.includes("MNote 页面 AI") && hiddenLegacyTabs;
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
|
||||||
|
null,
|
||||||
|
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected),
|
||||||
|
afterToken,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalDiskContent = fs.readFileSync(filePath, "utf8");
|
||||||
|
assert(finalDiskContent.includes(afterToken), "磁盘文件应包含 Board worker 写入后的标记");
|
||||||
|
assert(!finalDiskContent.includes(beforeToken), "磁盘文件不应再包含旧标记");
|
||||||
|
await waitForEditorText(page, afterToken);
|
||||||
|
assert.strictEqual(capturedBoardRuns.length, 1, `应只创建一个 Board Page AI run,实际 ${capturedBoardRuns.length}`);
|
||||||
|
const runBody = capturedBoardRuns[0].body;
|
||||||
|
assert.strictEqual(runBody.workflowId, "builtin-mnote-page-ai-chat", "Page AI 应默认走 MNote 专用 Board workflow");
|
||||||
|
assert.strictEqual(runBody.workerPresetId, "mnote-page-ai-zcode", "Page AI 应默认走 MNote 专用 ZCode worker");
|
||||||
|
assert.strictEqual(runBody.modelOverride, "zcode-fast", "Page AI 应传递 worker 内模型档位");
|
||||||
|
assert.strictEqual(runBody.envelope?.schema, "mnote.page_ai.board_task.v1", "run payload 应携带 Board envelope");
|
||||||
|
assert.strictEqual(runBody.envelope?.modelOverride, "zcode-fast", "envelope 应记录模型档位");
|
||||||
|
assert.strictEqual(runBody.envelope?.primaryTarget?.absolutePath, filePath, "primaryTarget 应指向当前真实 Markdown 文件");
|
||||||
|
assert(Array.isArray(runBody.envelope?.capabilities), "envelope 应携带 MNote capabilities");
|
||||||
|
assert(runBody.envelope.capabilities.includes("mnote.current_page.read"), "capabilities 应包含当前页读取能力");
|
||||||
|
assert(runBody.envelope.capabilities.includes("mnote.local_file.receipt"), "capabilities 应包含本地文件收据能力");
|
||||||
|
|
||||||
|
const drawerText = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({ timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(!drawerText.includes("Agent Board run 已创建"), "默认聊天面不应把 Board run 创建日志作为 assistant 气泡显示");
|
||||||
|
assert(drawerText.includes(`已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`), "主聊天应展示 receipt.finalAnswer 自然回复");
|
||||||
|
assert(!drawerText.includes("## Completed"), "主聊天不应展示 Board Completed 报告标题");
|
||||||
|
await page.locator(`[data-page-ai-board-run-detail="board-run-${suffix}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expectedPath) => {
|
||||||
|
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||||
|
return text.includes("Run detail") && text.includes("changed files") && text.includes(expectedPath) && text.includes("timeline");
|
||||||
|
},
|
||||||
|
filePath,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
const restoredStorage = await page.evaluate(() => {
|
||||||
|
const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:"));
|
||||||
|
return JSON.stringify(keys.map((key) => ({ key, value: window.localStorage.getItem(key) || "" })));
|
||||||
|
});
|
||||||
|
if (!restoredStorage.includes(afterToken)) {
|
||||||
|
throw new assert.AssertionError({
|
||||||
|
message: `刷新后 localStorage 应保留 Board-first session/history: ${restoredStorage.slice(0, 1000)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected),
|
||||||
|
afterToken,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const screenshot = await saveScreenshot(page, "01-board-first-edit");
|
||||||
|
const result = {
|
||||||
|
ok: true,
|
||||||
|
task: TASK,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
root,
|
||||||
|
rootUri,
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
relativePath,
|
||||||
|
filePath,
|
||||||
|
beforeToken,
|
||||||
|
afterToken,
|
||||||
|
screenshot,
|
||||||
|
capturedBoardRuns,
|
||||||
|
capturedBoardRunResponses,
|
||||||
|
drawerText,
|
||||||
|
consoleErrors,
|
||||||
|
finalDiskContent,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
await saveScreenshot(page, "failure").catch(() => undefined);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUTPUT_DIR, "failure.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||||
|
capturedBoardRuns,
|
||||||
|
consoleErrors,
|
||||||
|
root,
|
||||||
|
rootUri,
|
||||||
|
filePath,
|
||||||
|
diskContent: fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "",
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caughtError) {
|
||||||
|
throw caughtError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/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,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
ensureAuthenticated,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const TASK = "task763-page-ai-opencode-embed-smoke";
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||||
|
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||||
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||||
|
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium"]
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
async function saveScreenshot(page, name) {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
const target = path.join(OUTPUT_DIR, `${name}.png`);
|
||||||
|
await page.screenshot({ path: target, fullPage: true });
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVisibleAny(page, selectors, label) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(candidateSelectors) => candidateSelectors.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;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
selectors,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
).catch((error) => {
|
||||||
|
throw new Error(`${label} 不可见。候选选择器: ${selectors.join(", ")}\n${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectOpencodeHooks(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
const bySelector = (selectors) => selectors.flatMap((selector) =>
|
||||||
|
Array.from(document.querySelectorAll(selector)).map((node) => ({
|
||||||
|
selector,
|
||||||
|
tag: node.tagName.toLowerCase(),
|
||||||
|
text: (node.textContent || "").trim().slice(0, 120),
|
||||||
|
visible: visible(node),
|
||||||
|
href: node.getAttribute("href") || "",
|
||||||
|
src: node.getAttribute("src") || "",
|
||||||
|
action: node.getAttribute("data-page-ai-action") || "",
|
||||||
|
testid: node.getAttribute("data-testid") || "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const hostSelectors = [
|
||||||
|
"[data-page-ai-opencode-host]",
|
||||||
|
"[data-testid='page-ai-opencode-host']",
|
||||||
|
"[data-page-ai-host-chrome]",
|
||||||
|
"[data-page-ai-opencode-chrome]",
|
||||||
|
".wolai-page-ai-opencode-host",
|
||||||
|
".wolai-page-ai-opencode-chrome",
|
||||||
|
".wolai-page-ai-host-chrome",
|
||||||
|
];
|
||||||
|
const frameSelectors = [
|
||||||
|
"iframe[data-page-ai-opencode-iframe]",
|
||||||
|
"iframe[data-page-ai-opencode-frame]",
|
||||||
|
"iframe[data-testid='page-ai-opencode-frame']",
|
||||||
|
"iframe[src*='/page-ai/opencode']",
|
||||||
|
"iframe[src*='opencode']",
|
||||||
|
];
|
||||||
|
const changedFileSelectors = [
|
||||||
|
"[data-page-ai-opencode-changed-files]",
|
||||||
|
"[data-page-ai-changed-file-chip]",
|
||||||
|
"[data-page-ai-opencode-open-file]",
|
||||||
|
"[data-page-ai-changed-file]",
|
||||||
|
"[data-page-ai-action='open-changed-file']",
|
||||||
|
"[data-page-ai-action='open-file']",
|
||||||
|
"[data-page-ai-board-run-detail-card] .wolai-page-ai-tool-details",
|
||||||
|
".wolai-page-ai-changed-file-chip",
|
||||||
|
".wolai-page-ai-changed-files",
|
||||||
|
];
|
||||||
|
const openSelectors = [
|
||||||
|
"[data-page-ai-opencode-open-file]",
|
||||||
|
"[data-page-ai-action='open-changed-file']",
|
||||||
|
"[data-page-ai-action='open-current-file']",
|
||||||
|
"[data-page-ai-action='open-current-page']",
|
||||||
|
"[data-page-ai-action='open-file']",
|
||||||
|
"[data-page-ai-open-file]",
|
||||||
|
];
|
||||||
|
const refreshSelectors = [
|
||||||
|
"[data-page-ai-action='opencode-refresh-current-page']",
|
||||||
|
"[data-page-ai-refresh-file]",
|
||||||
|
"[data-page-ai-action='refresh-current-file']",
|
||||||
|
"[data-page-ai-action='refresh-current-page']",
|
||||||
|
"[data-page-ai-action='refresh-file']",
|
||||||
|
"[data-page-ai-refresh-file]",
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
title: document.title,
|
||||||
|
url: location.href,
|
||||||
|
drawerVisible: Boolean(Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible)),
|
||||||
|
hostChrome: bySelector(hostSelectors),
|
||||||
|
frames: bySelector(frameSelectors),
|
||||||
|
changedFiles: bySelector(changedFileSelectors),
|
||||||
|
openHooks: bySelector(openSelectors),
|
||||||
|
refreshHooks: bySelector(refreshSelectors),
|
||||||
|
htmlFlags: {
|
||||||
|
receiptCurrentRefresh: document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") || "",
|
||||||
|
receiptFiletreeRefresh: document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh") || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAnyVisible(items, label) {
|
||||||
|
assert(
|
||||||
|
items.some((item) => item.visible),
|
||||||
|
`${label} 缺失或不可见: ${JSON.stringify(items, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAnyHook(items, label) {
|
||||||
|
assert(items.length > 0, `${label} DOM hook 缺失`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
const screenshots = [];
|
||||||
|
const consoleMessages = [];
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.HEADFUL !== "1",
|
||||||
|
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (["error", "warning"].includes(message.type())) {
|
||||||
|
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
if (await quickLogin.count()) {
|
||||||
|
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
await ensureAuthenticated(page, context.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForSelector('[data-testid="wolai-floating-ai"]', { timeout: UI_TIMEOUT_MS });
|
||||||
|
const firstVisibleMarkdown = page.locator('[data-document-id^="local-md:"] button.tree-link, button[data-document-id^="local-md:"]').filter({ visible: true });
|
||||||
|
if (await firstVisibleMarkdown.count()) {
|
||||||
|
await firstVisibleMarkdown.first().click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口");
|
||||||
|
const drawerAlreadyOpen = await page.locator("[data-testid='wolai-page-ai-drawer']").count().then(async (count) => {
|
||||||
|
if (!count) return false;
|
||||||
|
return page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
|
||||||
|
});
|
||||||
|
if (!drawerAlreadyOpen) {
|
||||||
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI 抽屉");
|
||||||
|
await waitForVisibleAny(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
"[data-page-ai-opencode-host]",
|
||||||
|
"[data-testid='page-ai-opencode-host']",
|
||||||
|
"[data-page-ai-host-chrome]",
|
||||||
|
"[data-page-ai-opencode-chrome]",
|
||||||
|
".wolai-page-ai-opencode-host",
|
||||||
|
".wolai-page-ai-opencode-chrome",
|
||||||
|
".wolai-page-ai-host-chrome",
|
||||||
|
"iframe[data-page-ai-opencode-iframe]",
|
||||||
|
"iframe[data-page-ai-opencode-frame]",
|
||||||
|
"iframe[data-testid='page-ai-opencode-frame']",
|
||||||
|
"iframe[src*='/page-ai/opencode']",
|
||||||
|
"iframe[src*='opencode']",
|
||||||
|
],
|
||||||
|
"opencode iframe 或 host chrome",
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const frame = document.querySelector("iframe[data-page-ai-opencode-iframe]");
|
||||||
|
return frame instanceof HTMLIFrameElement && /\/session\/ses_/.test(frame.src || "");
|
||||||
|
},
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hooks = await collectOpencodeHooks(page);
|
||||||
|
assert(hooks.drawerVisible, "Page AI 抽屉未保持可见");
|
||||||
|
assertAnyVisible([...hooks.hostChrome, ...hooks.frames], "opencode iframe/host chrome");
|
||||||
|
assert(
|
||||||
|
hooks.frames.some((frame) => /\/session\/ses_/.test(frame.src || "")),
|
||||||
|
`opencode iframe 未进入绑定 session URL: ${JSON.stringify(hooks.frames, null, 2)}`,
|
||||||
|
);
|
||||||
|
assert(!hooks.hostChrome.some((item) => /Agent Board|ZCode|Hermes|Reasonix/.test(item.text || "")), "opencode host chrome 混入旧 Page AI provider 文案");
|
||||||
|
assertAnyHook(hooks.changedFiles, "changed files 容器或 hook");
|
||||||
|
assertAnyHook(hooks.refreshHooks, "refresh file hook");
|
||||||
|
screenshots.push(await saveScreenshot(page, "opencode-page-ai-open"));
|
||||||
|
|
||||||
|
const firstSessionUrl = hooks.frames.find((frame) => /\/session\/ses_/.test(frame.src || ""))?.src || "";
|
||||||
|
const secondContext = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||||
|
const secondPage = await secondContext.newPage();
|
||||||
|
secondPage.on("console", (message) => {
|
||||||
|
if (["error", "warning"].includes(message.type())) {
|
||||||
|
consoleMessages.push({ type: `second:${message.type()}`, text: message.text() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await secondPage.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
const secondQuickLogin = secondPage.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
if (await secondQuickLogin.count()) {
|
||||||
|
await secondQuickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await secondPage.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
await ensureAuthenticated(secondPage, secondContext.request);
|
||||||
|
}
|
||||||
|
await secondPage.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await secondPage.waitForSelector('[data-testid="wolai-floating-ai"]', { timeout: UI_TIMEOUT_MS });
|
||||||
|
const secondFirstVisibleMarkdown = secondPage.locator('[data-document-id^="local-md:"] button.tree-link, button[data-document-id^="local-md:"]').filter({ visible: true });
|
||||||
|
if (await secondFirstVisibleMarkdown.count()) {
|
||||||
|
await secondFirstVisibleMarkdown.first().click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await secondPage.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
await waitForVisibleAny(secondPage, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "第二浏览器 Page AI 入口");
|
||||||
|
const secondDrawerAlreadyOpen = await secondPage.locator("[data-testid='wolai-page-ai-drawer']").count().then(async (count) => {
|
||||||
|
if (!count) return false;
|
||||||
|
return secondPage.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
|
||||||
|
});
|
||||||
|
if (!secondDrawerAlreadyOpen) {
|
||||||
|
await secondPage.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
await secondPage.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const frame = document.querySelector("iframe[data-page-ai-opencode-iframe]");
|
||||||
|
return frame instanceof HTMLIFrameElement && /\/session\/ses_/.test(frame.src || "");
|
||||||
|
},
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const secondHooks = await collectOpencodeHooks(secondPage);
|
||||||
|
const secondSessionUrl = secondHooks.frames.find((frame) => /\/session\/ses_/.test(frame.src || ""))?.src || "";
|
||||||
|
assert.strictEqual(secondSessionUrl, firstSessionUrl, `跨浏览器 session binding 未复用: first=${firstSessionUrl} second=${secondSessionUrl}`);
|
||||||
|
screenshots.push(await saveScreenshot(secondPage, "opencode-page-ai-second-browser"));
|
||||||
|
hooks.secondBrowser = { frames: secondHooks.frames, sessionUrl: secondSessionUrl };
|
||||||
|
} finally {
|
||||||
|
await secondContext.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
hooks,
|
||||||
|
screenshots,
|
||||||
|
consoleMessages,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
screenshots.push(await saveScreenshot(page, "failure").catch(() => ""));
|
||||||
|
result = {
|
||||||
|
ok: false,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||||
|
screenshots: screenshots.filter(Boolean),
|
||||||
|
consoleMessages,
|
||||||
|
hooks: await collectOpencodeHooks(page).catch(() => null),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/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 cssPath = path.join(repoRoot, 'rust/crates/mnote-web/src/ssr/styles/components/page-ai.css');
|
||||||
|
const runtime = fs.readFileSync(runtimePath, 'utf8');
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['opencode host switch', runtime.includes('mnote.page_ai.opencode_host')],
|
||||||
|
['opencode status api', runtime.includes('/api/page-ai/opencode/status')],
|
||||||
|
['opencode diff api', runtime.includes('/api/page-ai/opencode/diff?sessionId=')],
|
||||||
|
['opencode event api', runtime.includes('/api/page-ai/opencode/events') && runtime.includes('new EventSource')],
|
||||||
|
['opencode iframe route', runtime.includes('/page-ai/opencode/') && runtime.includes('src="about:blank"')],
|
||||||
|
['persistent binding source', runtime.includes('/api/page-ai/opencode/session') && !runtime.includes('sessionStorage.setItem(storageKey')],
|
||||||
|
['changed file opener', runtime.includes('__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path: targetPath })')],
|
||||||
|
['current page refresh', runtime.includes('__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: \'page-ai-opencode-host\' })')],
|
||||||
|
['no interval polling', !/setInterval\s*\(/.test(runtime)],
|
||||||
|
['opencode css scope', css.includes('[data-page-ai-opencode-host="true"]')],
|
||||||
|
['opencode iframe css', css.includes('.wolai-page-ai-opencode-iframe')],
|
||||||
|
];
|
||||||
|
|
||||||
|
const failed = checks.filter(([, ok]) => !ok);
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('Page AI opencode host static smoke failed:');
|
||||||
|
for (const [name] of failed) console.error(`- ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Page AI opencode host static smoke passed.');
|
||||||
Reference in New Issue
Block a user