From 83feece021e1427647a2ee55174d8476769e9e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=A3=AE?= Date: Wed, 22 Jul 2026 08:10:25 +0800 Subject: [PATCH] Implement desktop workflows and native annotation overlay --- .gitignore | 1 + RPA.md | 416 ++++ agent/chat_sync.go | 3 +- demos/node-sphere-lab.html | 604 +++++ docs/api-design.md | 2074 +++++++++++++++++ docs/base-ui.css | 808 +++++++ docs/development.md | 214 ++ docs/go-backend-development-plan.md | 1068 +++++++++ docs/page-completion-audit.md | 817 +++++++ docs/page-prototype-wireframes.md | 1087 +++++++++ docs/src-ui-design-summary.md | 624 +++++ overlay.html | 12 + package-lock.json | 488 ++++ package.json | 3 + public/media/refund-guide.mp4 | Bin 0 -> 98484 bytes public/media/refund-process.svg | 19 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 47 + src-tauri/src/lib.rs | 740 +++++- src-tauri/src/window_capture.rs | 1568 +++++++++++++ src-tauri/tauri.conf.json | 149 +- src/App.jsx | 97 +- src/components/AgentChatPanel.jsx | 206 ++ src/components/MainShell.jsx | 4 +- src/components/NodeSphere.jsx | 398 ++++ src/components/Segmented.jsx | 11 +- src/components/Terminal.jsx | 9 +- src/components/TitleBar.jsx | 7 +- src/components/WindowUI.jsx | 90 + src/components/ui/select.jsx | 26 +- src/data/mockData.js | 4 +- src/index.css | 260 ++- src/overlay.css | 442 ++++ src/overlay.js | 708 ++++++ src/pages/AnnotationPage.jsx | 365 ++- src/pages/ClonePage.jsx | 357 +-- src/pages/DistillPage.jsx | 190 +- src/pages/KnowledgePage.jsx | 44 +- src/pages/SkillsPage.jsx | 49 +- src/utils/documentText.js | 73 + src/utils/navigation.js | 42 +- src/windows/DistillWindows.jsx | 55 + src/windows/EngineLogsWindow.jsx | 50 + src/windows/EngineWorkbench.jsx | 274 --- src/windows/KnowledgeWindows.jsx | 418 ++++ src/windows/SecondaryWindow.jsx | 56 +- src/windows/SettingsContent.jsx | 128 +- src/windows/SkillWindow.jsx | 159 ++ vision_test/app.py | 6 - vite.config.js | 7 + wechat_vision/requirements-windows.txt | 5 + wechat_vision/screen_srk_click_send.py | 7 +- .../start_wechat_algorithm_live_windows.ps1 | 50 + .../stop_wechat_algorithm_live_windows.ps1 | 26 + wechat_vision/wechat_algorithm_live.py | 19 +- wechat_vision/wechat_window_live.py | 164 +- 57 files changed, 14678 insertions(+), 872 deletions(-) create mode 100644 RPA.md create mode 100644 demos/node-sphere-lab.html create mode 100644 docs/api-design.md create mode 100644 docs/base-ui.css create mode 100644 docs/development.md create mode 100644 docs/go-backend-development-plan.md create mode 100644 docs/page-completion-audit.md create mode 100644 docs/page-prototype-wireframes.md create mode 100644 docs/src-ui-design-summary.md create mode 100644 overlay.html create mode 100644 public/media/refund-guide.mp4 create mode 100644 public/media/refund-process.svg create mode 100644 src-tauri/src/window_capture.rs create mode 100644 src/components/AgentChatPanel.jsx create mode 100644 src/components/NodeSphere.jsx create mode 100644 src/components/WindowUI.jsx create mode 100644 src/overlay.css create mode 100644 src/overlay.js create mode 100644 src/utils/documentText.js create mode 100644 src/windows/DistillWindows.jsx create mode 100644 src/windows/EngineLogsWindow.jsx delete mode 100644 src/windows/EngineWorkbench.jsx create mode 100644 src/windows/KnowledgeWindows.jsx create mode 100644 src/windows/SkillWindow.jsx delete mode 100644 vision_test/app.py create mode 100644 wechat_vision/requirements-windows.txt create mode 100644 wechat_vision/start_wechat_algorithm_live_windows.ps1 create mode 100644 wechat_vision/stop_wechat_algorithm_live_windows.ps1 diff --git a/.gitignore b/.gitignore index 7dde481..bebb45c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ .vite/ +.local-tools/ src-tauri/target/ src-tauri/gen/ diff --git a/RPA.md b/RPA.md new file mode 100644 index 0000000..74f58c1 --- /dev/null +++ b/RPA.md @@ -0,0 +1,416 @@ +# Desktop Mark:Ctrl+1 后的标注逻辑技术说明 + +本文只总结当前项目中 **按下 `Ctrl+1` 之后** 的窗口选择与标注逻辑,不包含主页面其它功能。 +下面的所有代码:E:\workspace\wechat_rap +--- + +## 1. 入口:全局快捷键 `Ctrl+1` + +代码位置:`src-tauri/src/window_capture.rs`、`src-tauri/src/lib.rs` + +### 1.1 启动时注册快捷键 +应用启动时: + +- 在 `lib.rs` 中调用 `window_capture::spawn_shortcut_listener(app.handle().clone())` +- 该函数在后台线程中通过 Win32 `RegisterHotKey` 注册: + +```rust +RegisterHotKey(null_mut(), 1, MOD_CONTROL as u32, b'1' as u32) +``` + +含义: + +- `MOD_CONTROL`:Ctrl +- `b'1'`:数字键 1 +- `id = 1`:该快捷键内部 ID +yu +### 1.2 消息循环监听 +快捷键不是前端监听,而是 Rust 后台线程通过 Win32 消息循环监听: + +```rust +while GetMessageW(&mut message, null_mut(), 0, 0) > 0 { + if message.message == WM_HOTKEY && message.wParam == 1 { + ... + } +} +``` + +当收到 `WM_HOTKEY` 且 `wParam == 1` 时,说明用户按下了 `Ctrl+1`。 + +--- + +## 2. 快捷键触发后:进入窗口选择模式 + +代码位置:`src-tauri/src/window_capture.rs` + +按下 `Ctrl+1` 后,Rust 会调用: + +```rust +enter_window_select_mode_internal(&app, &state) +``` + +### 2.1 切换内部状态 +该函数会更新 `OverlayStore`: + +```rust +store.target_window_id = None; +store.mode = OverlayMode::WindowSelect; +``` + +当前状态含义: + +- `target_window_id = None`:还没有确认选中哪个真实应用窗口 +- `mode = WindowSelect`:前端 overlay 进入“窗口选择”模式,而不是“标注框绘制”模式 + +### 2.2 打开 Overlay 窗口 +然后调用: + +```rust +show_overlay_window(app) +``` + +作用: + +- 根据虚拟屏幕范围设置 overlay 位置与尺寸 +- 显示 overlay 窗口 +- 重新聚焦 overlay + +Overlay 特征(见 `lib.rs`): + +- `transparent(true)`:透明 +- `decorations(false)`:无边框 +- `always_on_top(true)`:置顶 +- `visible(false)`:默认隐藏,按需显示 +- `skip_taskbar(true)`:不进任务栏 + +因此,`Ctrl+1` 的直接结果是: + +```text +全屏透明置顶 Overlay 被显示出来,进入窗口选择模式 +``` + +--- + +## 3. Overlay 前端:窗口选择模式如何工作 + +代码位置:`src/overlay.ts`、`overlay.html`、`src/overlay.css` + +### 3.1 Overlay 的数据来源 +前端通过轮询: + +```ts +invoke('get_overlay_frame') +``` + +从 Rust 获取当前帧状态。 + +`OverlayFrame` 关键字段: + +- `mode`: `'annotation' | 'window_select'` +- `virtual_screen_rect` +- `target_window` +- `hover_window` +- `cursor` +- `annotations` +- `node_tree` + +在 `Ctrl+1` 后: + +```text +mode = window_select +``` + +### 3.2 鼠标移到哪个窗口,哪个窗口高亮 +Rust 侧通过: + +```rust +hover_target() +``` + +获取鼠标所在真实窗口。 + +逻辑要点: + +1. 先用 `GetCursorPos` 取屏幕坐标 +2. 再通过 `WindowFromPoint` + `GetAncestor(..., GA_ROOT)` 拿到鼠标下的顶层窗口 +3. 再向下过滤,跳过: + - Desktop Mark 自己 + - 工具窗口 + - 输入法 / 任务栏 / 桌面 / 阴影等系统窗口 +4. 最终得到一个可选的真实桌面应用窗口 `hover_window` + +### 3.3 前端高亮绘制 +在 `overlay.ts` 里: + +- 整屏先绘制半透明遮罩 +- 当前 `hover_window` 对应区域被镂空 +- 再绘制高亮边框与提示标签 + +窗口选择模式下,标签文本类似: + +```text +点击选择:窗口标题 · x=... , y=... · DPI ...% +``` + +因此用户看到的体验是: + +```text +Ctrl+1 → 整屏暗化 → 鼠标移到哪个应用窗口,哪个窗口被高亮 +``` + +--- + +## 4. 点击窗口:确认目标窗口 + +代码位置:`src/overlay.ts`、`src-tauri/src/window_capture.rs` + +### 4.1 前端点击逻辑 +在 `overlay.ts` 中,`pointerup` 时如果当前模式是 `window_select`: + +```ts +await invoke('select_target_window', { windowId: frame.hover_window.window_id }); +frame = await invoke('get_overlay_frame'); +``` + +也就是说: + +- 用户点击当前高亮窗口 +- 把这个窗口的 `window_id` 发送给 Rust + +### 4.2 Rust 侧确认目标窗口 +对应命令: + +```rust +select_target_window(window_id, app, state) +``` + +它会做三件事: + +#### a. 校验窗口存在 + +```rust +find_window_by_id(&window_id) +``` + +#### b. 把目标窗口提到前台 + +```rust +bring_window_to_front(window.hwnd) +``` + +内部会: + +- `ShowWindow(hwnd, SW_RESTORE)` 恢复窗口 +- `SetWindowPos(... HWND_TOPMOST ...)` 暂时提到最上层 +- `SetForegroundWindow(hwnd)` 设为前台 + +#### c. 切换 Overlay 模式 + +```rust +store.target_window_id = Some(window_id); +store.mode = OverlayMode::Annotation; +``` + +此时进入: + +```text +标注模式(Annotation) +``` + +#### d. 再次显示并聚焦 Overlay + +```rust +show_overlay_window(&app) +``` + +这样可以避免用户选完窗口后还要额外点一下别处,才能开始画标注框。 + +--- + +## 5. 标注模式:只显示当前目标窗口的标注框 + +代码位置:`src-tauri/src/window_capture.rs` + +用户确认窗口后,后续 `get_overlay_frame()` 不会再返回所有标注,而是只返回: + +```rust +当前 target_window_id 对应的 annotations +``` + +实现逻辑: + +```rust +let visible_annotations = if store.mode == OverlayMode::Annotation { + store + .target_window_id + .as_ref() + .map(|window_id| { + store + .annotations + .iter() + .filter(|annotation| &annotation.window_id == window_id) + .cloned() + .collect() + }) + .unwrap_or_default() +} else { + Vec::new() +}; +``` + +效果: + +- 切到窗口 A 标注时,只显示窗口 A 的标注框 +- 切到窗口 B 标注时,窗口 A 的标注框会隐藏,但数据不会丢 + +--- + +## 6. 在目标窗口内拖拽创建标注框 + +代码位置:`src/overlay.ts`、`src-tauri/src/window_capture.rs` + +### 6.1 前端拖拽 +在 `annotation` 模式下: + +- `pointerdown` 记录起点 +- `pointermove` 更新终点 +- `pointerup` 生成屏幕坐标矩形 `RectInfo` + +然后调用: + +```ts +invoke('add_annotation', { + input: { + window_id: frame.target_window.window_id, + screen_rect: rect, + label: null, + action_kind: 'click', + description: null, + } +}) +``` + +### 6.2 Rust 保存标注 +命令: + +```rust +add_annotation(input, state) +``` + +Rust 会做这些校验: + +1. 目标窗口是否仍存在 +2. 标注框尺寸是否太小 +3. 标注框是否完整位于目标窗口内部 + +通过后,生成: + +- `annotation.id` +- `label` +- `action_kind` +- `description` +- `screen_rect` +- `normalized_rect` +- `feature_image_path` + +### 6.3 特征图生成 +保存标注时还会生成一个本地 BMP 特征图: + +```rust +capture_feature_image(&format!("ann-{index}"), input.screen_rect) +``` + +特征图保存目录: + +```text +%TEMP%/desktop_mark/feature_images/ +``` + +--- + +## 7. 标注数据如何持久化 + +代码位置:`src-tauri/src/window_capture.rs` + +当前标注逻辑不是只存在内存里,而是会持久化到本地: + +### 7.1 状态文件 + +```text +./data/workflow-state.json +``` + +### 7.2 持久化内容 +`PersistedWorkflowState` 包含: + +- `app_name` +- `app_description` +- `annotations` +- `next_annotation_index` + +### 7.3 触发时机 +以下操作后都会持久化: + +- `add_annotation` +- `update_annotation` +- `update_annotation_geometry` +- `delete_annotation` +- `clear_annotations` +- `update_app_metadata` + +--- + +## 8. Ctrl+1 后逻辑的完整时序 + +```text +用户按下 Ctrl+1 +→ Rust Win32 热键线程收到 WM_HOTKEY +→ 进入 WindowSelect 模式 +→ 显示全屏透明 Overlay +→ 鼠标移动,Rust 持续计算 hover_window +→ 前端绘制高亮窗口 +→ 用户点击高亮窗口 +→ Rust 记录 target_window_id,并切到 Annotation 模式 +→ Overlay 再次聚焦 +→ 用户直接在目标窗口内拖拽 +→ 前端把矩形发给 add_annotation +→ Rust 校验并保存 annotation + feature image +→ 当前窗口的标注框持续显示,其它窗口标注框隐藏 +``` + +--- + +## 9. 当前 Ctrl+1 方案的核心特点 + +### 优点 + +- 全局快捷键直接进入窗口选择,不依赖前端焦点 +- Overlay 与真实应用窗口分离,不污染目标应用 +- 只显示当前目标窗口的标注框,切窗不混乱 +- 标注数据与特征图会落本地 +- DPI、虚拟屏、多窗口过滤都在 Rust/Win32 层处理 + +### 当前限制 + +- 目标窗口关系、节点关系仍主要依赖本地状态和后续页面逻辑 +- Ctrl+1 只负责“选窗 + 进入标注模式”,不负责主页面流程编排 +- feature image 当前保存为 BMP,不是 PNG + +--- + +## 10. 涉及文件清单 + +### Rust 后端 + +- `src-tauri/src/lib.rs` +- `src-tauri/src/window_capture.rs` + +### Overlay 前端 + +- `overlay.html` +- `src/overlay.ts` +- `src/overlay.css` + +### 类型定义 + +- `src/types.ts` diff --git a/agent/chat_sync.go b/agent/chat_sync.go index 405e668..dbcdc4a 100644 --- a/agent/chat_sync.go +++ b/agent/chat_sync.go @@ -104,7 +104,6 @@ func runChatSync() error { return err } logInfo(fmt.Sprintf("聊天记录同步完成,messages=%d path=%s", len(archive.Messages), archivePath)) - return nil } @@ -132,7 +131,7 @@ func scrollChatToTop(config Config, summaries []RegionSummary, screenshotPath st return captureWindow(window, screenshotPath) } -func scrollChat(config Config, summaries []RegionSummary, deltaY int) error { +func scrollChat(_ Config, summaries []RegionSummary, deltaY int) error { chat, ok := regionByType(summaries)["chat_content"] if !ok { return fmt.Errorf("chat_content region not found") diff --git a/demos/node-sphere-lab.html b/demos/node-sphere-lab.html new file mode 100644 index 0000000..94fd36b --- /dev/null +++ b/demos/node-sphere-lab.html @@ -0,0 +1,604 @@ + + + + + + Node Sphere Lab · 节点球实验室 + + + + + +
+
+
+
+ + Node Sphere Lab +
+ +
+
+ + + +
+ + localhost/demos/node-sphere-lab.html +
+ +
+
+ +
+ + +
+
+
+
+
+

轨道星环

+ LIVE +
+

节点围绕核心多轨道运行,粒子沿轨迹持续旋转

+
+
+ + +
+
+ +
+ +
+ FPS 60 + PARTICLES 96 +
+ +
+ + + +
+
+ +
+
+
总节点12
+
+
+
运行中8
+
+
+
未启动2
+
+
+
异常错误2
+
+
+
+
+
+ + + + diff --git a/docs/api-design.md b/docs/api-design.md new file mode 100644 index 0000000..0683c5c --- /dev/null +++ b/docs/api-design.md @@ -0,0 +1,2074 @@ +# 微信 AI 助手 API 接口设计 + +> 依据当前 `src/` 页面字段、数据结构和交互设计。本文定义目标契约,不表示这些接口已经实现。 +> +> 适用范围:单机 Windows、单个本地操作者、Tauri 桌面端、本机 SQLite 业务状态、本机 sidecar。默认不引入账号、租户、Workspace 或远程同步。 + +## 1. 设计结论 + +### 1.1 协议选择 + +| 场景 | 协议 | 原因 | +|---|---|---| +| 列表、详情、筛选、创建、更新、启停、删除、配置保存 | HTTP JSON | 一次请求对应一个确定结果,便于重试、校验、审计和错误处理 | +| 文件上传 | HTTP `multipart/form-data`;大文件使用分片 HTTP | WebSocket 不适合传二进制大文件;HTTP 更容易限制大小、校验哈希和断点续传 | +| 智能体测试流式回复 | WebSocket | 同一会话需要发送消息、接收 token 增量、完成或失败事件 | +| 蒸馏任务进度 | WebSocket | 后台任务跨页面持续运行,需要实时阶段和进度事件 | +| 引擎状态、启动检查、结构化日志 | WebSocket | 状态和日志持续产生,轮询会增加延迟和重复流量 | +| 知识文件解析状态 | WebSocket | PDF、DOCX、PPT/PPTX 的文本提取可能耗时;图片和视频只做上传、校验及预览,不做自动识别 | +| 标注截图、窗口选择、区域保存、桌面窗口控制 | Tauri IPC | 全程由本地 React/Tauri/Rust 处理,不进入 HTTP、WebSocket 或 Go 后端服务 | +| 主题、当前 Tab、临时弹窗状态 | 前端本地状态 | 纯 UI 状态,不进入业务 API | + +### 1.2 服务地址 + +- HTTP:`http://127.0.0.1:8766/api/v1` +- WebSocket:`ws://127.0.0.1:8766/api/v1/ws?ticket={singleUseTicket}` +- 现有视觉预览服务使用 `127.0.0.1:8765`,业务 API 使用 `8766`,避免端口冲突。 +- 服务必须只监听 loopback,不监听局域网地址。 + +### 1.3 安全边界 + +1. Tauri 启动 sidecar 时生成随机 `sessionToken`,HTTP 使用 `Authorization: Bearer {sessionToken}`。 +2. WebSocket 不在 URL 中携带长期 token。前端先申请 30 秒有效、单次使用的 `ticket`。 +3. 模型 API Key 进入系统密钥库;任何 GET 响应只返回 `keyConfigured`,不得返回明文或掩码后的原始值。 +4. 日志禁止记录 API Key、完整系统提示词、无关聊天原文、剪贴板内容和本机绝对文件路径。 +5. 所有资源更新使用 `revision` 做乐观并发控制;版本不匹配返回 `409 REVISION_CONFLICT`。 +6. 删除知识库、智能体、员工等不可逆操作必须由前端二次确认,并在请求中传 `confirmation`。 + +--- + +## 2. 通用规范 + +### 2.1 数据格式 + +- JSON 字段统一使用 `camelCase`,与 React/Tauri 当前数据结构一致。 +- 时间统一为 UTC RFC 3339,例如 `2026-07-21T08:30:15.238Z`。 +- 文件大小统一为整数 `sizeBytes`,`KB/MB` 仅由前端格式化。 +- 比率统一为 `0~1` 小数,例如 `successRate: 0.984`;分数使用 `0~100`。 +- ID 不使用名称,建议前缀:`kb_`、`kv_`、`file_`、`agent_`、`employee_`、`distill_`、`test_`、`log_`。 +- 列表使用 cursor 分页;页面当前数据量虽小,接口仍保持可扩展。 + +### 2.2 成功响应 + +```json +{ + "data": {}, + "meta": { + "requestId": "req_01J3...", + "nextCursor": null, + "total": 6 + } +} +``` + +- 单资源接口可省略 `nextCursor` 和 `total`。 +- `204 No Content` 用于成功删除且无响应体的场景。 + +### 2.3 错误响应 + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "请求字段校验失败", + "retryable": false, + "fieldErrors": { + "title": "知识标题不能为空" + }, + "details": null + }, + "requestId": "req_01J3..." +} +``` + +常用状态码和错误码: + +| HTTP | code | 含义 | +|---:|---|---| +| 400 | `VALIDATION_ERROR` | 字段格式、枚举或业务前置条件错误 | +| 401 | `UNAUTHORIZED` | 本机 session token 无效或过期 | +| 404 | `NOT_FOUND` | 资源不存在或已删除 | +| 409 | `REVISION_CONFLICT` | 乐观锁版本冲突 | +| 409 | `STATE_CONFLICT` | 当前状态不允许该操作 | +| 413 | `FILE_TOO_LARGE` | 文件超过用途限制 | +| 415 | `UNSUPPORTED_FILE_TYPE` | 文件扩展名、MIME 或内容签名不受支持 | +| 422 | `FILE_PARSE_FAILED` | 文件上传成功但无法解析 | +| 429 | `RATE_LIMITED` | 模型或任务并发达到限制 | +| 500 | `INTERNAL_ERROR` | 未分类内部错误 | +| 502 | `MODEL_UPSTREAM_ERROR` | 模型供应商错误 | +| 504 | `MODEL_TIMEOUT` | 模型调用超时 | + +### 2.4 列表参数 + +```text +?query=退款&status=enabled&type=file&limit=20&cursor=eyJpZCI6... +``` + +| 参数 | 类型 | 默认 | 说明 | +|---|---|---:|---| +| `query` | string | `""` | 名称、标签或正文关键字,最大 100 字 | +| `status` | string | `all` | 各资源定义的状态枚举 | +| `limit` | integer | 20 | `1~100` | +| `cursor` | string | 无 | 服务端返回的游标,不允许客户端解析 | +| `sort` | string | `updatedAt:desc` | 白名单排序字段与方向 | + +### 2.5 幂等性 + +以下 POST 请求必须携带 `Idempotency-Key`: + +- 创建知识库草稿、发布知识版本; +- 创建智能体、发送测试消息; +- 创建蒸馏任务、发布员工; +- 启动或停止引擎。 + +服务端在 24 小时内对相同 key 和相同请求体返回首次结果;同 key 不同请求体返回 `409 IDEMPOTENCY_CONFLICT`。 + +--- + +## 3. 核心数据对象 + +### 3.1 FileAsset + +```json +{ + "id": "file_01J3...", + "purpose": "knowledge_source", + "fileName": "refund-policy.pdf", + "contentType": "application/pdf", + "extension": "pdf", + "sizeBytes": 2516582, + "sha256": "6fe1...", + "kind": "text", + "status": "ready", + "parseStatus": "completed", + "extractedTextLength": 12680, + "convertedTextFileId": "filetxt_01J3...", + "warnings": [ + { "code": "EMBEDDED_IMAGES_SKIPPED", "message": "文档中的 3 张图片已省略" } + ], + "error": null, + "previewStatus": "ready", + "description": null, + "descriptionSource": null, + "createdAt": "2026-07-21T08:30:15.238Z" +} +``` + +枚举: + +- `purpose`: `knowledge_source | agent_test_attachment | distill_source | report_export` +- `kind`: `text | image | video | binary` +- `status`: `uploading | uploaded | processing | ready | failed | deleted` +- `parseStatus`: `not_required | queued | processing | completed | failed` +- `previewStatus`: `none | processing | ready | failed` +- `descriptionSource`: 当前只允许 `manual | null`,禁止返回 `ai | ocr | vision` + +图片或视频知识文件示例: + +```json +{ + "id": "file_01J4...", + "purpose": "knowledge_source", + "fileName": "refund-process.png", + "contentType": "image/png", + "extension": "png", + "sizeBytes": 356240, + "sha256": "7a2d...", + "kind": "image", + "status": "ready", + "parseStatus": "not_required", + "extractedTextLength": 0, + "convertedTextFileId": null, + "warnings": [], + "error": null, + "previewStatus": "ready", + "description": "退款申请、审核和到账三个步骤的流程图。", + "descriptionSource": "manual", + "createdAt": "2026-07-21T08:31:15.238Z" +} +``` + +### 3.2 KnowledgeSummary + +```json +{ + "id": "kb_refund", + "title": "售后常见问题与退款边界", + "type": "file", + "tags": ["售后", "退款"], + "status": "enabled", + "currentVersion": "v1.7", + "referenceCount": 82, + "fileCount": 4, + "updatedAt": "2026-06-05T01:42:00Z", + "revision": 7 +} +``` + +- `type`: `file | image | video | mixed` +- `status`: `draft | processing | enabled | disabled | failed` + +### 3.3 AgentSummary + +```json +{ + "id": "agent_intent", + "name": "客户意图识别", + "type": "builtin", + "enabled": true, + "version": "v2.1", + "tags": ["线索分级", "异议判断", "下一步建议"], + "knowledgeCount": 3, + "recentCallCount": 16, + "lastTestStatus": "passed", + "updatedAt": "2026-06-16T10:20:00Z", + "revision": 4 +} +``` + +- `type`: `builtin | custom` +- `lastTestStatus`: `not_run | running | passed | failed` + +### 3.4 DistillJobSummary + +```json +{ + "id": "distill_01J3...", + "name": "销售冠军 Aileen 话术蒸馏", + "state": "extracting", + "stage": "strategy_extraction", + "progress": 0.72, + "estimatedRemainingSeconds": 180, + "sourceCount": 1, + "successCount": 248, + "failedCount": 7, + "createdAt": "2026-07-21T08:30:15.238Z", + "updatedAt": "2026-07-21T08:34:10.000Z", + "revision": 9 +} +``` + +- `state`: `draft | queued | extracting | evaluating | ready_to_publish | completed | cancelled | failed` +- `stage`: `source_validation | data_cleaning | scene_clustering | strategy_extraction | boundary_induction | counterexample_generation | evaluation | completed` + +### 3.5 EmployeeSummary + +```json +{ + "id": "employee_1", + "name": "销售冠军 Aileen", + "version": "v3.2", + "enabled": true, + "traits": ["高意向逼单", "异议拆解", "温和推进"], + "domain": "private_sales", + "score": 96, + "weeklyCallCount": 28, + "updatedAt": "2026-06-18T09:00:00Z", + "revision": 3 +} +``` + +--- + +## 4. 应用初始化与实时连接 + +### 4.1 获取启动数据 + +`GET /bootstrap` + +请求:无 body。 + +响应: + +```json +{ + "data": { + "apiVersion": "1.0", + "serverVersion": "0.1.0", + "platform": "windows", + "capabilities": { + "knowledge": true, + "agents": true, + "distillation": true, + "engine": true, + "nativeAnnotation": true + }, + "engineState": { + "lifecycle": "idle", + "autoSendPaused": false, + "annotationComplete": true, + "updatedAt": "2026-07-21T08:30:15.238Z" + }, + "badges": { + "clone": 3, + "knowledge": 0, + "agents": 1, + "distill": 0 + } + } +} +``` + +用途:主窗口一次请求获得能力开关、引擎初始状态和底部 Tab badge。主题仍存本地,不进入接口。 + +### 4.2 申请 WebSocket ticket + +`POST /realtime-tickets` + +请求: + +```json +{ + "clientId": "desktop-main-7cb8", + "lastEventId": "evt_01J3...", + "subscriptions": ["engine", "logs", "distill:distill_01J3", "files:file_01J3"] +} +``` + +字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `clientId` | string | 是 | 每个窗口唯一,最大 64 字 | +| `lastEventId` | string/null | 否 | 断线续传起点 | +| `subscriptions` | string[] | 否 | 初始订阅频道,最多 50 个 | + +响应: + +```json +{ + "data": { + "ticket": "wst_01J3...", + "webSocketUrl": "ws://127.0.0.1:8766/api/v1/ws?ticket=wst_01J3...", + "expiresAt": "2026-07-21T08:30:45.238Z", + "heartbeatSeconds": 20 + } +} +``` + +--- + +## 5. 微信分身、引擎与日志 + +### 5.1 引擎状态 + +`GET /engine/state` + +响应 `EngineState`: + +```json +{ + "data": { + "lifecycle": "running", + "agent": "running", + "visionStream": "running", + "wechatWindow": "connected", + "annotationComplete": true, + "autoSendPaused": false, + "activeConversationId": null, + "consecutiveErrors": 0, + "lastError": null, + "startedAt": "2026-07-21T08:30:15.238Z", + "updatedAt": "2026-07-21T08:30:18.000Z", + "revision": 12 + } +} +``` + +枚举: + +- `lifecycle`: `idle | checking | starting | running | stopping | error` +- `agent` / `visionStream`: `stopped | starting | running | error` +- `wechatWindow`: `unknown | connected | missing | changed` + +### 5.2 执行启动检查 + +`POST /engine/startup-checks` + +请求: + +```json +{ "forceRefresh": true } +``` + +响应 `202 Accepted`: + +```json +{ + "data": { + "runId": "check_01J3...", + "state": "running", + "checks": [ + { "id": "config", "label": "基础配置", "status": "waiting", "passed": null, "detail": null }, + { "id": "annotation", "label": "界面标注", "status": "waiting", "passed": null, "detail": null }, + { "id": "window", "label": "微信窗口连接情况", "status": "waiting", "passed": null, "detail": null } + ] + } +} +``` + +后续通过 `engine.startup_check.updated` WS 事件返回逐项状态;最终结果也可通过 `GET /engine/startup-checks/{runId}` 查询。 + +### 5.3 启动引擎 + +`POST /engine/start` + +请求: + +```json +{ + "startupCheckRunId": "check_01J3...", + "expectedRevision": 12 +} +``` + +响应 `202 Accepted`:返回最新 `EngineState`。若检查未通过,返回 `409 STARTUP_CHECK_FAILED`,`details.failedCheckIds` 指明失败项。 + +### 5.4 停止引擎 + +`POST /engine/stop` + +请求: + +```json +{ "expectedRevision": 13, "reason": "operator_request" } +``` + +响应 `202 Accepted`:返回 `lifecycle: "stopping"` 的 `EngineState`,最终状态通过 WS 推送。 + +### 5.5 暂停或恢复自动发送 + +`PATCH /engine/auto-send` + +请求: + +```json +{ "paused": true, "expectedRevision": 14 } +``` + +响应:返回最新 `EngineState`。当前界面的“暂停自动发送/恢复自动发送”直接绑定此接口。 + +### 5.6 日志列表 + +`GET /logs?level=warning&module=model&query=req_91fa&limit=100&cursor=...` + +响应: + +```json +{ + "data": { + "items": [ + { + "id": "log_01J3...", + "time": "2026-07-21T08:30:15.238Z", + "level": "warning", + "module": "model", + "message": "model gateway latency is higher than expected", + "requestId": "req_91fa", + "context": { + "fromSnapshotId": "snapshot_9821", + "toSnapshotId": "snapshot_9822" + } + } + ] + }, + "meta": { "requestId": "req_...", "nextCursor": null, "total": 1 } +} +``` + +- `level`: `info | warning | error` +- `module`: `agent | vision | capture | retriever | model | policy | sender` + +暂停接收仅停止当前窗口的 WS 消费,不调用后端、不暂停引擎。清空视图只清前端缓存,不删除审计日志。 + +### 5.7 导出日志 + +`POST /logs/exports` + +请求: + +```json +{ + "filter": { "level": "all", "module": "all", "query": "" }, + "format": "jsonl", + "from": "2026-07-20T00:00:00Z", + "to": "2026-07-21T23:59:59Z" +} +``` + +响应 `202 Accepted`: + +```json +{ + "data": { + "exportId": "export_01J3...", + "status": "processing", + "fileId": null + } +} +``` + +完成后发送 `export.completed`,payload 中提供 `fileId`;前端调用 `GET /files/{fileId}/content` 下载。 + +--- + +## 6. 文件上传、解析与预览 + +### 6.1 用途限制 + +| purpose | 支持格式 | 单文件限制 | 数量限制 | +|---|---|---:|---:| +| `agent_test_attachment` | PDF、DOCX、PPT、PPTX、TXT、MD、CSV、JSON | 20 MB | 每条消息最多 5 个 | +| `knowledge_source` | TXT、PDF、DOCX、PPT、PPTX、PNG、JPG/JPEG、GIF、WEBP、MP4、WEBM、MOV | 文档/图片 100 MB;视频 2 GB | 每个草稿最多 100 个 | +| `distill_source` | TXT、MD、CSV、JSON、PDF、DOCX、PPT、PPTX | 500 MB | 每个任务最多 50 个 | + +服务端必须同时校验扩展名、MIME 和文件头;不能只信任浏览器提交的 `contentType`。 + +知识文件首期处理边界: + +1. TXT 按 UTF-8 规范化保存;PDF 只提取已有文本层;DOCX 提取段落和表格文字;PPT/PPTX 提取文本框、表格和备注文字。 +2. 文档中的嵌入图片直接省略,不调用 OCR、视觉模型或图片理解。 +3. 图片和视频可以作为独立知识来源上传、保存和预览,但不执行 OCR、图片理解、视频理解、ASR、关键帧提取或自动摘要。 +4. 图片和视频的 `description` 只能由操作者人工填写,`descriptionSource` 固定为 `manual`;后端不得自动生成或补全描述。 +5. 媒体文件有人工描述时,知识检索只索引标题、标签和人工描述;没有描述时只索引标题和标签,原始媒体内容不进入文本检索。 +6. 扫描版 PDF 或只有图片、没有可提取文字的 Office 文档返回 `422 NO_EXTRACTABLE_TEXT`,不能标记为文档转换成功。 +7. 文档转换结果生成独立 UTF-8 `.txt` 文件,并通过 `convertedTextFileId` 关联原文件;知识版本正文由转换后的 TXT 和媒体人工描述组成。 + +### 6.2 小文件直接上传 + +`POST /files` + +Content-Type:`multipart/form-data` + +表单字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `file` | binary | 是 | 文件内容 | +| `purpose` | enum | 是 | 上述用途 | +| `clientFileId` | string | 是 | 前端本次选择生成的 UUID,用于去重 | +| `sha256` | string | 否 | 客户端可计算时传入 | + +响应 `201 Created`:返回 `FileAsset`。文本类文件通常返回 `parseStatus: "queued"`,不能把上传成功展示成解析成功。 + +### 6.3 大文件分片初始化 + +`POST /file-uploads` + +请求: + +```json +{ + "purpose": "knowledge_source", + "fileName": "product-training.mp4", + "contentType": "video/mp4", + "sizeBytes": 734003200, + "sha256": "可选整体哈希", + "clientFileId": "8fb2a9e5-..." +} +``` + +响应: + +```json +{ + "data": { + "uploadId": "upload_01J3...", + "chunkSizeBytes": 8388608, + "uploadedParts": [], + "expiresAt": "2026-07-22T08:30:15.238Z" + } +} +``` + +### 6.4 上传分片 + +`PUT /file-uploads/{uploadId}/parts/{partNumber}` + +Headers: + +```text +Content-Type: application/octet-stream +Content-Length: 8388608 +Content-Range: bytes 0-8388607/734003200 +X-Chunk-SHA256: 1c9e... +``` + +响应: + +```json +{ + "data": { + "partNumber": 1, + "sizeBytes": 8388608, + "sha256": "1c9e...", + "accepted": true + } +} +``` + +重复上传同一分片且哈希相同必须幂等;哈希不同返回 `409 CHUNK_CONFLICT`。 + +### 6.5 完成分片上传 + +`POST /file-uploads/{uploadId}/complete` + +请求: + +```json +{ + "parts": [ + { "partNumber": 1, "sha256": "1c9e..." }, + { "partNumber": 2, "sha256": "41af..." } + ] +} +``` + +响应 `201 Created`:返回 `FileAsset`。 + +### 6.6 查询和取消上传 + +- `GET /file-uploads/{uploadId}`:返回已上传分片,用于断点续传。 +- `DELETE /file-uploads/{uploadId}`:删除临时分片,返回 `204`。 + +### 6.7 查询文件 + +`GET /files/{fileId}`:返回 `FileAsset`。 + +文件解析过程通过 WS 推送 `file.processing.updated`: + +```json +{ + "fileId": "file_01J3...", + "status": "processing", + "parseStatus": "processing", + "progress": 0.64, + "stage": "extracting_text", + "extractedTextLength": 0, + "error": null +} +``` + +### 6.8 文档转换与媒体预览 + +- `GET /files/{fileId}/text?offset=0&limit=20000`:分页预览文档转换后的纯文本。 +- `GET /files/{fileId}/text-file`:下载 UTF-8 `.txt` 转换结果,响应为 `text/plain; charset=utf-8`。 +- `GET /files/{fileId}/content`:下载原文件或预览图片、视频;支持 `Range`。 +- `PATCH /files/{fileId}`:只用于操作者保存图片或视频的人工描述。 + +文档转换规则: + +- PDF 只读取文本层,不对页面截图或扫描内容执行 OCR。 +- DOCX 中的段落、表格文本按文档顺序输出;图片、形状内不可直接读取的图像文字省略。 +- PPT/PPTX 中的文本框、表格和备注按页输出;每页使用稳定页码分隔,图片内容省略。 +- 成功提取到正文但省略了图片时,`parseStatus` 为 `completed`,同时返回 `EMBEDDED_IMAGES_SKIPPED` warning。 +- 没有任何可提取正文时,`parseStatus` 为 `failed`,`error.code` 为 `NO_EXTRACTABLE_TEXT`。 + +媒体处理规则: + +- 图片、视频上传完成并通过文件安全校验后,`parseStatus` 固定为 `not_required`。 +- 服务端只生成安全预览能力,不识别画面、不抽取文字、不识别语音、不生成描述。 +- `description` 完全来自人工输入,服务端原样保存;空描述允许保存,但媒体内容本身不会进入 FTS5 正文索引。 + +`PATCH /files/{fileId}` 请求: + +```json +{ + "description": "退款申请、审核和到账三个步骤的流程图。", + "descriptionSource": "manual", + "expectedRevision": 1 +} +``` + +服务端必须拒绝 `descriptionSource` 不是 `manual` 的请求;响应返回最新 `FileAsset`。 + +文本响应: + +```json +{ + "data": { + "fileId": "file_01J3...", + "text": "第一章 退款申请范围……", + "offset": 0, + "returnedLength": 12680, + "totalLength": 12680, + "truncated": false + } +} +``` + +### 6.9 删除文件 + +`DELETE /files/{fileId}?expectedRevision=1` + +- 未绑定草稿或消息时返回 `204`。 +- 已被已发布版本引用时返回 `409 FILE_IN_USE`,不能物理删除。 + +--- + +## 7. 知识库接口 + +### 7.1 知识库列表与全文搜索 + +`GET /knowledge-bases?query=退款&status=all&limit=20&cursor=...` + +响应:`data.items: KnowledgeSummary[]`。 + +搜索范围由设置中的 `knowledgeSearchFields` 决定;页面输入“标题、标签或完整正文”时,服务端返回匹配后的知识库摘要,不把完整正文放进列表响应。 + +### 7.2 创建草稿 + +`POST /knowledge-drafts` + +请求: + +```json +{ + "mode": "create", + "knowledgeId": null, + "baseVersion": null, + "title": "售后退款政策", + "description": "用于回答售后退款范围、异常订单与客户安抚相关问题。", + "tags": ["私域成交", "售后"] +} +``` + +更新知识库时: + +```json +{ + "mode": "update", + "knowledgeId": "kb_refund", + "baseVersion": "v1.7" +} +``` + +响应: + +```json +{ + "data": { + "id": "kbdraft_01J3...", + "mode": "create", + "knowledgeId": null, + "baseVersion": null, + "title": "售后退款政策", + "description": "用于回答……", + "tags": ["私域成交", "售后"], + "fileIds": [], + "state": "draft", + "revision": 1, + "updatedAt": "2026-07-21T08:30:15.238Z" + } +} +``` + +### 7.3 保存草稿基本信息 + +`PATCH /knowledge-drafts/{draftId}` + +请求: + +```json +{ + "title": "售后退款政策", + "description": "用于回答售后问题。", + "tags": ["私域成交", "售后"], + "expectedRevision": 1 +} +``` + +校验:`title` 1~100 字;`description` 最大 2000 字;标签最多 20 个,每个 1~30 字,去重后保存。 + +响应:返回完整草稿和递增后的 `revision`。 + +### 7.4 绑定或移除来源文件 + +`POST /knowledge-drafts/{draftId}/files` + +```json +{ + "fileIds": ["file_01J3...", "file_01J4..."], + "expectedRevision": 2 +} +``` + +响应: + +```json +{ + "data": { + "draftId": "kbdraft_01J3...", + "files": [{ "id": "file_01J3...", "fileName": "refund-policy.pdf", "sizeBytes": 2516582, "kind": "text", "status": "ready", "parseStatus": "completed" }], + "readyCount": 1, + "processingCount": 1, + "failedCount": 0, + "revision": 3 + } +} +``` + +`DELETE /knowledge-drafts/{draftId}/files/{fileId}?expectedRevision=3`:从草稿移除,返回更新后的文件汇总。 + +### 7.5 草稿预览汇总 + +`GET /knowledge-drafts/{draftId}/preview` + +响应: + +```json +{ + "data": { + "draftId": "kbdraft_01J3...", + "sourceCount": 3, + "ready": true, + "files": [{ "id": "file_01J3...", "fileName": "refund-policy.pdf", "sizeBytes": 2516582, "kind": "text", "status": "ready", "parseStatus": "completed" }], + "totalTextLength": 26000, + "blockingErrors": [] + } +} +``` + +`ready` 只有在至少一个来源且所有必需解析完成时为 `true`。 + +### 7.6 发布知识库或保存新版本 + +`POST /knowledge-drafts/{draftId}/publish` + +请求: + +```json +{ + "versionNote": "补充异常订单处理边界", + "enableAfterPublish": true, + "expectedRevision": 4 +} +``` + +响应 `201 Created`: + +```json +{ + "data": { + "knowledge": { "id": "kb_refund", "title": "售后常见问题与退款边界", "type": "file", "tags": ["售后", "退款"], "status": "enabled", "currentVersion": "v1.8", "referenceCount": 0, "fileCount": 1, "updatedAt": "2026-07-21T08:40:00Z", "revision": 1 }, + "version": { + "id": "kv_01J3...", + "knowledgeId": "kb_refund", + "version": "v1.8", + "versionNote": "补充异常订单处理边界", + "fileIds": ["file_01J3..."], + "textLength": 26000, + "contentHash": "5c82...", + "createdAt": "2026-07-21T08:40:00Z" + } + } +} +``` + +版本号由服务端生成,客户端不得自行加一。若 `baseVersion` 已落后,返回 `409 REVISION_CONFLICT`。 + +### 7.7 知识库详情 + +`GET /knowledge-bases/{knowledgeId}` + +响应: + +```json +{ + "data": { + "id": "kb_refund", + "title": "售后常见问题与退款边界", + "description": "涵盖退款期限、数字商品限制、异常订单处理和安抚话术边界。", + "tags": ["售后", "退款"], + "type": "file", + "status": "enabled", + "currentVersion": "v1.7", + "creator": { "id": "local_operator", "displayName": "本机管理员" }, + "effectiveAt": "2026-06-05T01:42:00Z", + "lastRetrievedAt": "2026-07-21T08:18:00Z", + "metrics": { + "weeklyHitCount": 32, + "agentReferenceCount": 3, + "employeeBindingCount": 2, + "textLength": 26000 + }, + "revision": 7, + "updatedAt": "2026-06-05T01:42:00Z" + } +} +``` + +### 7.8 详情子资源 + +- `GET /knowledge-bases/{id}/files`:当前版本的 `FileAsset[]`。 +- `GET /knowledge-bases/{id}/references?type=all`:引用关系。 +- `GET /knowledge-bases/{id}/versions?limit=20&cursor=...`:版本历史。 + +引用关系项: + +```json +{ + "resourceType": "agent", + "resourceId": "agent_quote", + "name": "报价策略生成", + "enabled": true, + "boundVersion": "v1.7" +} +``` + +版本项: + +```json +{ + "id": "kv_01J3...", + "version": "v1.7", + "versionNote": "修正数字商品退款边界", + "isCurrent": true, + "createdAt": "2026-06-05T01:42:00Z" +} +``` + +### 7.9 启停和删除 + +`PATCH /knowledge-bases/{id}/status` + +```json +{ "enabled": false, "expectedRevision": 7 } +``` + +响应:返回最新 `KnowledgeSummary`。停用后已绑定关系保留,但检索时不能命中。 + +`DELETE /knowledge-bases/{id}` + +```json +{ + "confirmation": "售后常见问题与退款边界", + "expectedRevision": 8 +} +``` + +有引用关系时默认返回 `409 RESOURCE_IN_USE` 和引用摘要;不提供静默级联删除。 + +--- + +## 8. 智能体接口 + +### 8.1 列表 + +`GET /agents?query=报价&type=all&enabled=all&limit=20&cursor=...` + +响应:`data.items: AgentSummary[]`。 + +筛选枚举:`type=all|builtin|custom`,`enabled=all|true|false`。 + +### 8.2 创建智能体草稿 + +`POST /agent-drafts` + +请求: + +```json +{ + "mode": "create", + "agentId": null, + "name": "报价策略生成", + "description": "根据客户意向、商品成本和优惠边界生成报价建议。", + "tags": ["利润保护", "阶梯报价", "优惠边界"] +} +``` + +更新模式传 `mode: "update"` 和 `agentId`。响应: + +```json +{ + "data": { + "id": "agentdraft_01J3...", + "mode": "create", + "agentId": null, + "name": "报价策略生成", + "description": "根据客户意向……", + "tags": ["利润保护", "阶梯报价", "优惠边界"], + "systemPrompt": "", + "knowledgeIds": [], + "lastTestSessionId": null, + "lastTestStatus": "not_run", + "revision": 1 + } +} +``` + +### 8.3 保存基本信息、提示词和授权 + +`PATCH /agent-drafts/{draftId}` + +请求可部分更新: + +```json +{ + "name": "报价策略生成", + "description": "根据客户意向、商品成本和优惠边界生成报价建议。", + "tags": ["利润保护", "阶梯报价", "优惠边界"], + "systemPrompt": "你是报价策略助手……", + "knowledgeIds": ["kb_price", "kb_discount"], + "expectedRevision": 1 +} +``` + +校验:名称 1~80 字;描述最大 2000 字;标签最多 20 个;系统提示词最大 20000 字;只能授权已生效知识库。 + +响应:返回完整草稿和新 `revision`。 + +### 8.4 发布或更新智能体 + +`POST /agent-drafts/{draftId}/publish` + +请求: + +```json +{ + "releaseNote": "首次发布报价策略生成智能体。", + "enableAfterPublish": true, + "expectedRevision": 3 +} +``` + +前置条件:`lastTestStatus` 必须为 `passed`,且测试使用的 `draftRevision` 必须等于当前草稿 revision;提示词或授权变更后旧测试失效。 + +响应: + +```json +{ + "data": { + "agent": { "id": "agent_quote", "name": "报价策略生成", "type": "custom", "enabled": true, "version": "v1.0", "tags": ["利润保护", "阶梯报价", "优惠边界"], "knowledgeCount": 2, "recentCallCount": 0, "lastTestStatus": "passed", "updatedAt": "2026-07-21T08:50:00Z", "revision": 1 }, + "release": { + "version": "v1.0", + "releaseNote": "首次发布报价策略生成智能体。", + "createdAt": "2026-07-21T08:50:00Z" + } + } +} +``` + +### 8.5 详情 + +`GET /agents/{agentId}` + +响应: + +```json +{ + "data": { + "id": "agent_intent", + "name": "客户意图识别", + "type": "builtin", + "enabled": true, + "version": "v2.1", + "description": "识别客户所处阶段、购买意向、核心异议,并输出下一步跟进建议。", + "tags": ["线索分级", "异议判断", "下一步建议"], + "systemPrompt": "你是报价策略助手……", + "permissions": [ + { "code": "knowledge.query", "label": "查询知识库", "granted": true }, + { "code": "conversation.read_current", "label": "读取当前会话", "granted": true }, + { "code": "message.send", "label": "直接发送消息", "granted": false } + ], + "knowledge": [ + { "id": "kb_refund", "title": "售后退款政策", "version": "v1.7", "enabled": true } + ], + "metrics": { + "recentCallCount": 16, + "successRate": 0.984, + "averageLatencyMs": 1200, + "knowledgeCount": 3 + }, + "updatedAt": "2026-06-16T10:20:00Z", + "revision": 4 + } +} +``` + +### 8.6 权限和测试记录 + +- `GET /agents/{id}/permissions`:返回上述 `permissions` 和 `knowledge`。 +- `GET /agents/{id}/test-records?limit=20&cursor=...`。 + +测试记录项: + +```json +{ + "id": "test_01J3...", + "scenario": "高意向询价", + "status": "passed", + "latencyMs": 1200, + "draftRevision": 3, + "createdAt": "2026-06-18T09:30:00Z", + "error": null +} +``` + +### 8.7 启停和删除 + +`PATCH /agents/{id}/status` + +```json +{ "enabled": false, "expectedRevision": 4 } +``` + +`DELETE /agents/{id}` + +```json +{ + "confirmation": "客户意图识别", + "expectedRevision": 5 +} +``` + +内置智能体可按产品规则返回 `403 BUILTIN_AGENT_READ_ONLY`;若允许删除,必须明确为隐藏/禁用而非物理删除。 + +--- + +## 9. 智能体测试会话与 WebSocket 流式回复 + +### 9.1 创建测试会话 + +`POST /agent-test-sessions` + +请求: + +```json +{ + "agentId": "agent_intent", + "draftId": null, + "draftRevision": null, + "mode": "published" +} +``` + +编辑器沙箱传: + +```json +{ + "agentId": null, + "draftId": "agentdraft_01J3...", + "draftRevision": 3, + "mode": "draft" +} +``` + +响应: + +```json +{ + "data": { + "sessionId": "testsession_01J3...", + "state": "ready", + "channel": "agent-test:testsession_01J3...", + "maxFilesPerMessage": 5, + "maxFileSizeBytes": 20971520, + "acceptedExtensions": ["pdf", "docx", "ppt", "pptx", "txt", "md", "csv", "json"], + "expiresAt": "2026-07-21T10:00:00Z" + } +} +``` + +### 9.2 发送测试消息 + +使用 WebSocket command,不把文件二进制放进 WS。附件必须先通过 `POST /files` 上传。 + +```json +{ + "type": "agent.test.message.send", + "requestId": "client_req_8", + "payload": { + "sessionId": "testsession_01J3...", + "clientMessageId": "bce8e547-...", + "content": "客户预算为 10000 元,可以如何报价?", + "fileIds": ["file_01J3..."] + } +} +``` + +校验:`content` 与 `fileIds` 至少一个非空;内容最大 20000 字;附件最多 5 个;附件 purpose 必须为 `agent_test_attachment` 且解析完成。 + +服务端事件顺序: + +1. `agent.test.message.accepted` +2. 零到多个 `agent.test.message.delta` +3. `agent.test.message.completed` 或 `agent.test.message.failed` + +accepted: + +```json +{ + "sessionId": "testsession_01J3...", + "messageId": "msg_01J3...", + "clientMessageId": "bce8e547-...", + "assistantMessageId": "msg_01J4...", + "acceptedAt": "2026-07-21T08:55:00Z" +} +``` + +delta: + +```json +{ + "sessionId": "testsession_01J3...", + "assistantMessageId": "msg_01J4...", + "sequence": 7, + "delta": "建议先核对", + "accumulatedLength": 28 +} +``` + +completed: + +```json +{ + "sessionId": "testsession_01J3...", + "assistantMessageId": "msg_01J4...", + "content": "建议先核对客户需求……", + "finishReason": "stop", + "usage": { "inputTokens": 412, "outputTokens": 96 }, + "latencyMs": 1320, + "testResult": { + "status": "passed", + "draftRevision": 3, + "warnings": [] + }, + "completedAt": "2026-07-21T08:55:01.320Z" +} +``` + +failed: + +```json +{ + "sessionId": "testsession_01J3...", + "assistantMessageId": "msg_01J4...", + "code": "MODEL_TIMEOUT", + "message": "模型在 8000ms 内未返回", + "retryable": true +} +``` + +### 9.3 历史与取消 + +- `GET /agent-test-sessions/{sessionId}/messages?limit=100&cursor=...`:恢复弹窗后的对话。 +- WS command `agent.test.generation.cancel`:`{ sessionId, assistantMessageId }`。 +- `DELETE /agent-test-sessions/{sessionId}`:结束会话并清理未引用测试附件。 + +消息对象: + +```json +{ + "id": "msg_01J3...", + "role": "user", + "content": "客户预算为 10000 元", + "files": [ + { "id": "file_01J3...", "fileName": "budget.txt", "sizeBytes": 126 } + ], + "state": "completed", + "createdAt": "2026-07-21T08:55:00Z" +} +``` + +--- + +## 10. 蒸馏任务接口 + +### 10.1 当前任务和员工列表 + +- `GET /distill-jobs?state=active&limit=10`:返回 `DistillJobSummary[]`,用于“当前任务”。 +- `GET /employees?query=Aileen&enabled=all&limit=20&cursor=...`:返回 `EmployeeSummary[]`。 + +### 10.2 创建蒸馏草稿 + +`POST /distill-drafts` + +请求: + +```json +{ + "name": "销售冠军 Aileen 话术蒸馏", + "minimumValidConversations": 50 +} +``` + +响应: + +```json +{ + "data": { + "id": "distilldraft_01J3...", + "name": "销售冠军 Aileen 话术蒸馏", + "fileIds": [], + "minimumValidConversations": 50, + "state": "draft", + "revision": 1 + } +} +``` + +### 10.3 绑定上传文件 + +`POST /distill-drafts/{draftId}/files` + +```json +{ + "fileIds": ["file_01J3..."], + "expectedRevision": 1 +} +``` + +响应:返回草稿、文件列表和有效内容统计: + +```json +{ + "data": { + "draftId": "distilldraft_01J3...", + "files": [{ "id": "file_01J3...", "fileName": "champion-scripts.docx", "sizeBytes": 245760, "kind": "text", "status": "ready", "parseStatus": "completed" }], + "statistics": { + "validConversationCount": 1286, + "scriptCount": 38, + "invalidRecordCount": 7 + }, + "revision": 2 + } +} +``` + +当前来源页只保留文件上传;不设计导入微信聊天、选择已有案例或日期范围字段。 + +### 10.4 保存草稿 + +`PATCH /distill-drafts/{draftId}` + +```json +{ + "name": "销售冠军 Aileen 话术蒸馏", + "minimumValidConversations": 50, + "expectedRevision": 2 +} +``` + +响应:返回完整草稿。 + +### 10.5 开始能力提取 + +`POST /distill-drafts/{draftId}/start` + +```json +{ "expectedRevision": 3 } +``` + +响应 `202 Accepted`:返回 `DistillJobSummary`。文件未解析完成、有效记录不足或草稿为空时返回 `400 VALIDATION_ERROR`。 + +任务进度通过 `distill.progress.updated` 推送: + +```json +{ + "jobId": "distill_01J3...", + "state": "extracting", + "stage": "strategy_extraction", + "stageLabel": "策略提取", + "progress": 0.72, + "processedCount": 1286, + "totalCount": 1286, + "stageCurrent": 13, + "stageTotal": 18, + "estimatedRemainingSeconds": 180, + "recognizedCapabilities": ["高意向识别", "异议拆解", "温和推进", "优惠边界", "长期跟进"], + "updatedAt": "2026-07-21T09:05:00Z", + "revision": 9 +} +``` + +### 10.6 查询任务详情 + +`GET /distill-jobs/{jobId}` + +响应: + +```json +{ + "data": { + "summary": { "id": "distill_01J3...", "name": "销售冠军 Aileen 话术蒸馏", "state": "extracting", "stage": "strategy_extraction", "progress": 0.72, "estimatedRemainingSeconds": 180, "sourceCount": 1, "successCount": 248, "failedCount": 7, "createdAt": "2026-07-21T08:30:15.238Z", "updatedAt": "2026-07-21T08:34:10Z", "revision": 9 }, + "stages": [ + { "code": "data_cleaning", "label": "数据清洗", "state": "completed", "current": 1286, "total": 1286 }, + { "code": "scene_clustering", "label": "场景聚类", "state": "completed", "current": 18, "total": 18 }, + { "code": "strategy_extraction", "label": "策略提取", "state": "running", "current": 13, "total": 18 }, + { "code": "boundary_induction", "label": "边界归纳", "state": "waiting", "current": 0, "total": null }, + { "code": "counterexample_generation", "label": "反例生成", "state": "waiting", "current": 0, "total": null } + ], + "recognizedCapabilities": ["高意向识别", "异议拆解", "温和推进", "优惠边界", "长期跟进"], + "error": null + } +} +``` + +页面已移除失败样本入口,因此不提供面向 UI 的失败样本明细接口;`failedCount` 只作为任务健康摘要和日志字段。 + +### 10.7 效果评估 + +`GET /distill-jobs/{jobId}/evaluation` + +响应: + +```json +{ + "data": { + "jobId": "distill_01J3...", + "state": "completed", + "overallScore": 92, + "publishThreshold": 85, + "publishable": true, + "metrics": { + "sceneHitRate": 0.94, + "refusalAccuracy": 0.91, + "overreachRate": 0.003, + "consistency": 0.89 + }, + "cases": [ + { "id": "case_1", "name": "高意向询价", "result": "建议合理", "passed": true }, + { "id": "case_2", "name": "超边界优惠", "result": "已拒绝越权", "passed": true }, + { "id": "case_3", "name": "售后退款混合场景", "result": "回答包含不确定信息", "passed": false } + ], + "completedAt": "2026-07-21T09:10:00Z" + } +} +``` + +`POST /distill-jobs/{jobId}/evaluation-runs`:重新评估,响应 `202`,进度通过 WS 推送。 + +`POST /distill-jobs/{jobId}/evaluation-exports`:生成报告,响应结构同日志导出。 + +### 10.8 发布员工 + +`POST /distill-jobs/{jobId}/publish` + +请求: + +```json +{ + "name": "销售冠军 Aileen", + "domain": "private_sales", + "agentIds": ["agent_intent", "agent_quote"], + "knowledgeIds": ["kb_price", "kb_discount", "kb_refund"], + "enableAfterPublish": true, + "expectedRevision": 11 +} +``` + +- `domain`: `private_sales | b2b_consulting | after_sales` +- 至少一个授权能力;知识授权可为空;只允许授权已启用资源。 + +响应 `201 Created`: + +```json +{ + "data": { + "employee": { "id": "employee_1", "name": "销售冠军 Aileen", "version": "v1.0", "enabled": true, "traits": ["高意向识别", "异议拆解"], "domain": "private_sales", "score": 92, "weeklyCallCount": 0, "updatedAt": "2026-07-21T09:15:00Z", "revision": 1 }, + "sourceJobId": "distill_01J3...", + "publishedAt": "2026-07-21T09:15:00Z" + } +} +``` + +### 10.9 取消任务 + +`POST /distill-jobs/{jobId}/cancel` + +```json +{ "expectedRevision": 9, "reason": "operator_request" } +``` + +仅 `queued | extracting | evaluating` 可取消。响应返回 `state: "cancelled"` 的任务摘要。 + +--- + +## 11. 员工详情接口 + +### 11.1 详情 + +`GET /employees/{employeeId}` + +响应: + +```json +{ + "data": { + "id": "employee_1", + "name": "销售冠军 Aileen", + "version": "v3.2", + "enabled": true, + "domain": "private_sales", + "traits": ["高意向逼单", "异议拆解", "温和推进"], + "profile": { + "applicableScenario": "已有明确需求、处于报价或决策阶段的私域客户", + "prohibitedBehavior": "承诺未授权折扣、绕过人工确认直接发送敏感信息" + }, + "metrics": { + "overallScore": 96, + "weeklyCallCount": 28, + "adoptionRate": 0.82, + "exceptionCount": 1 + }, + "agentBindings": [ + { "id": "agent_intent", "name": "客户意图识别", "granted": true } + ], + "knowledgeBindings": [ + { "id": "kb_price", "title": "产品价格表", "bound": true } + ], + "updatedAt": "2026-06-18T09:00:00Z", + "revision": 3 + } +} +``` + +### 11.2 启停、画像和授权 + +`PATCH /employees/{id}/status` + +```json +{ "enabled": false, "expectedRevision": 3 } +``` + +`PATCH /employees/{id}/profile` + +```json +{ + "name": "销售冠军 Aileen", + "domain": "private_sales", + "traits": ["高意向逼单", "异议拆解", "温和推进"], + "applicableScenario": "已有明确需求、处于报价或决策阶段的私域客户", + "prohibitedBehavior": "承诺未授权折扣", + "expectedRevision": 4 +} +``` + +`PUT /employees/{id}/bindings` + +```json +{ + "agentIds": ["agent_intent", "agent_quote"], + "knowledgeIds": ["kb_price", "kb_discount", "kb_sales_champion"], + "expectedRevision": 5 +} +``` + +均返回最新员工详情。 + +### 11.3 详情页各 Tab + +- `GET /employees/{id}/evaluation`:结构同蒸馏评估摘要。 +- `GET /employees/{id}/versions?limit=20&cursor=...`。 +- `GET /employees/{id}/calls?limit=50&cursor=...`。 + +调用记录项: + +```json +{ + "id": "call_01J3...", + "time": "2026-07-21T04:26:00Z", + "scenario": "高意向询价", + "result": "suggestion_adopted", + "resultLabel": "建议已采纳", + "requestId": "req_01J3..." +} +``` + +### 11.4 重新蒸馏和复制 + +`POST /employees/{id}/redistill` + +```json +{ "baseVersion": "v3.2" } +``` + +响应 `201`:返回预填的 `distillDraftId`。 + +`POST /employees/{id}/clone` + +```json +{ "name": "销售冠军 Aileen 副本", "copyBindings": true } +``` + +响应 `201`:返回新员工详情,默认 `enabled: false`。 + +--- + +## 12. 设置接口 + +### 12.1 获取全部配置 + +`GET /settings` + +响应: + +```json +{ + "data": { + "revision": 12, + "model": { + "provider": "volcengine", + "endpoint": "https://ark.cn-beijing.volces.com/api/v3", + "modelName": "doubao-pro-32k", + "requestTimeoutMs": 8000, + "keyConfigured": true, + "keyUpdatedAt": "2026-07-20T09:00:00Z" + }, + "prompt": { + "fixedPrompt": "你是本地微信分身助手,需要遵守托管策略、回复边界与用户授权。", + "customPrompt": "保持友好、简洁、可信赖。优先调用知识库和已授权员工能力。", + "customPromptMaxLength": 4000 + }, + "replyRules": { + "quietHoursEnabled": false, + "quietHours": { "start": "22:00", "end": "08:00", "timezone": "Asia/Shanghai" }, + "autoAddFriend": false, + "proactiveContact": false, + "distillationEnabled": true + }, + "featureGates": { + "agentsEnabled": true, + "employeesEnabled": true, + "knowledgeEnabled": true + }, + "knowledgeSearch": { + "algorithm": "sqlite_fts5_bm25", + "maxResults": 5, + "fields": ["title", "tags", "fullText"], + "activeKnowledgeIds": ["kb_sales_champion", "kb_refund"] + }, + "hosting": { + "enabled": false, + "localMessageServiceEnabled": true, + "confirmBeforeSend": true, + "abnormalPauseEnabled": true, + "consecutiveErrorLimit": 3, + "messageWaitSeconds": 30 + }, + "updatedAt": "2026-07-21T08:00:00Z" + } +} +``` + +### 12.2 保存配置 + +`PUT /settings` + +请求传完整可编辑配置,但不传 `fixedPrompt`、`keyConfigured`、`keyUpdatedAt`: + +```json +{ + "expectedRevision": 12, + "model": { + "provider": "volcengine", + "endpoint": "https://ark.cn-beijing.volces.com/api/v3", + "modelName": "doubao-pro-32k", + "requestTimeoutMs": 8000 + }, + "prompt": { + "customPrompt": "保持友好、简洁、可信赖。" + }, + "replyRules": { + "quietHoursEnabled": false, + "quietHours": { "start": "22:00", "end": "08:00", "timezone": "Asia/Shanghai" }, + "autoAddFriend": false, + "proactiveContact": false, + "distillationEnabled": true + }, + "featureGates": { + "agentsEnabled": true, + "employeesEnabled": true, + "knowledgeEnabled": true + }, + "knowledgeSearch": { + "maxResults": 5, + "fields": ["title", "tags", "fullText"], + "activeKnowledgeIds": ["kb_sales_champion", "kb_refund"] + }, + "hosting": { + "enabled": false, + "localMessageServiceEnabled": true, + "confirmBeforeSend": true, + "abnormalPauseEnabled": true, + "consecutiveErrorLimit": 3, + "messageWaitSeconds": 30 + } +} +``` + +响应:完整最新配置。前端“恢复”必须恢复最近一次 GET/PUT 成功的数据,不能只清除 dirty 状态。 + +### 12.3 API Key + +`PUT /settings/model-api-key` + +```json +{ "apiKey": "真实密钥" } +``` + +响应: + +```json +{ + "data": { + "keyConfigured": true, + "keyUpdatedAt": "2026-07-21T09:30:00Z" + } +} +``` + +`DELETE /settings/model-api-key`:返回 `204`。密钥只在此 PUT 请求中出现一次。 + +### 12.4 测试模型连接 + +`POST /settings/model-connection-tests` + +使用当前未保存表单值: + +```json +{ + "provider": "volcengine", + "endpoint": "https://ark.cn-beijing.volces.com/api/v3", + "modelName": "doubao-pro-32k", + "requestTimeoutMs": 8000, + "useStoredApiKey": true +} +``` + +响应: + +```json +{ + "data": { + "success": true, + "latencyMs": 324, + "provider": "volcengine", + "modelName": "doubao-pro-32k", + "checkedAt": "2026-07-21T09:31:00Z" + } +} +``` + +失败时仍返回对应 HTTP 错误和明确 code:`MODEL_AUTH_FAILED | MODEL_NOT_FOUND | MODEL_TIMEOUT | MODEL_NETWORK_UNREACHABLE`。 + +### 12.5 组合提示词预览 + +`POST /settings/prompt-previews` + +```json +{ "customPrompt": "保持友好、简洁、可信赖。" } +``` + +响应: + +```json +{ + "data": { + "fixedPrompt": "你是本地微信分身助手……", + "customPrompt": "保持友好、简洁、可信赖。", + "combinedPrompt": "你是本地微信分身助手……\n\n保持友好、简洁、可信赖。", + "characterCount": 52 + } +} +``` + +### 12.6 黑白名单 + +`GET /contact-lists?type=allow|block&query=张三` + +响应项: + +```json +{ + "id": "contact_rule_01J3...", + "type": "allow", + "displayName": "张三", + "headerFingerprint": "sha256:...", + "source": "manual", + "createdAt": "2026-07-21T09:32:00Z" +} +``` + +`POST /contact-lists` + +```json +{ + "type": "allow", + "displayName": "张三", + "headerFingerprint": "sha256:...", + "source": "manual" +} +``` + +`DELETE /contact-lists/{ruleId}`:返回 `204`。 + +仅名称不足以稳定识别微信会话;生产数据必须包含当前会话头生成的 `headerFingerprint`。 + +### 12.7 管理页逐项开关 + +以下接口复用资源状态,不额外创建第二套启停数据: + +- 智能体:`PATCH /agents/{id}/status` +- 员工:`PATCH /employees/{id}/status` +- 知识库:`PATCH /knowledge-bases/{id}/status` +- 三个总开关:`PUT /settings` 中的 `featureGates` + +--- + +## 13. WebSocket 通用协议 + +### 13.1 连接和订阅 + +连接成功事件: + +```json +{ + "eventId": "evt_01J3...", + "type": "connection.ready", + "occurredAt": "2026-07-21T09:35:00Z", + "payload": { + "clientId": "desktop-main-7cb8", + "heartbeatSeconds": 20, + "resumeAccepted": true + } +} +``` + +客户端订阅: + +```json +{ + "type": "subscribe", + "requestId": "client_req_1", + "payload": { + "channels": ["engine", "logs", "distill:distill_01J3", "files:file_01J3"] + } +} +``` + +服务端确认: + +```json +{ + "eventId": "evt_01J4...", + "type": "ack", + "occurredAt": "2026-07-21T09:35:00.010Z", + "requestId": "client_req_1", + "payload": { "acceptedChannels": ["engine", "logs", "distill:distill_01J3", "files:file_01J3"] } +} +``` + +### 13.2 服务端事件包络 + +```json +{ + "eventId": "evt_01J5...", + "type": "engine.state.changed", + "occurredAt": "2026-07-21T09:35:01Z", + "requestId": "req_01J3...", + "resource": { "type": "engine", "id": "local" }, + "payload": {} +} +``` + +### 13.3 事件清单 + +| type | channel | payload | +|---|---|---| +| `engine.state.changed` | `engine` | 完整 `EngineState`,不是局部 patch | +| `engine.startup_check.updated` | `engine` | `runId`、`state`、完整 `checks[]` | +| `engine.annotation.changed` | `engine` | `annotationComplete`、`revision`、`updatedAt` | +| `log.appended` | `logs` | 单条完整 `LogEvent` | +| `file.processing.updated` | `files:{fileId}` | 文件处理进度对象 | +| `knowledge.changed` | `knowledge:{id}` | `KnowledgeSummary` | +| `agent.changed` | `agent:{id}` | `AgentSummary` | +| `agent.test.message.accepted` | `agent-test:{sessionId}` | 消息受理对象 | +| `agent.test.message.delta` | `agent-test:{sessionId}` | token 增量对象 | +| `agent.test.message.completed` | `agent-test:{sessionId}` | 完成对象 | +| `agent.test.message.failed` | `agent-test:{sessionId}` | 错误对象 | +| `distill.progress.updated` | `distill:{jobId}` | 完整进度对象 | +| `distill.evaluation.updated` | `distill:{jobId}` | 评估状态和结果摘要 | +| `employee.changed` | `employee:{id}` | `EmployeeSummary` | +| `settings.changed` | `settings` | `revision`、`changedSections[]`;前端收到后重新 GET | +| `export.completed` | `exports:{exportId}` | `exportId`、`status`、`fileId`、`error` | + +### 13.4 心跳、重连和顺序 + +- 服务端每 20 秒发送 `ping`,客户端 10 秒内返回 `pong`。 +- 事件在单连接内按 `eventId` 顺序发送;业务顺序以资源 `revision` 为准。 +- 前端忽略 `revision` 小于当前状态的迟到事件。 +- 断线后指数退避:1s、2s、4s、8s,最大 15s。 +- 重连时用最后一个已处理 `eventId` 申请新 ticket;事件缓存建议保留 10 分钟。 +- 无法续传时 `connection.ready.payload.resumeAccepted=false`,前端必须重新 GET 当前页面资源。 +- WS 只负责通知和流式增量;创建、更新、删除仍由 HTTP 完成。 + +--- + +## 14. Tauri 原生 IPC:不得改成 HTTP/WS 的能力 + +这些接口仍是 API 契约,但调用形式只允许 `invoke(command, payload)`。标注调试全程在本机 React/Tauri/Rust 内完成,直接操作本机窗口、截图和应用生命周期。 + +本地边界: + +1. `src/pages/AnnotationPage.jsx` 和 `src/overlay.js` 不调用业务 HTTP 或 WebSocket。 +2. 截图字节、截图路径、窗口句柄、窗口元数据、标注区域和未保存会话状态不得发送给 Go 后端服务。 +3. Go 后端不提供标注调试接口,也不读取标注调试状态;保存和加载只通过 Tauri/Rust 本地存储。 +4. Tauri event 只在本机 WebView 窗口之间传递,不桥接到业务 WebSocket。 + +### 14.1 标注区域对象 + +```json +{ + "id": "region_01J3...", + "name": "聊天内容", + "type": "chat_content", + "description": "微信当前会话消息区", + "bbox_image": [120, 180, 860, 1240], + "bbox_source": [80, 120, 573.333, 826.667], + "bbox_screen": [420, 280, 1160, 1340], + "scaleFactor": 1.5 +} +``` + +坐标统一为 XYXY:`[x1, y1, x2, y2]`。生产必需类型: + +- `conversation_header` +- `chat_content` +- `input_box` +- `send_button` + +`custom` 只能附加,不能代替必需类型。 + +### 14.2 IPC 命令 + +| command | 请求 | 返回 | +|---|---|---| +| `enter_window_select_mode` | `{}` | `{ mode: "window_select" }` | +| `capture_screen` | `{}` | `CaptureResult` | +| `read_screenshot_bytes` | `{ path }` | `number[]` 或二进制 buffer | +| `load_regions` | `{}` | `AnnotationConfig | null` | +| `save_regions` | `{ annotation: AnnotationConfig }` | `{ path, revision, updatedAt }` | +| `start_vision_stream` | `{}` | `{ state: "running" }` | +| `stop_vision_stream` | `{}` | `{ state: "stopped" }` | +| `start_agent` | `{}` | `{ state: "running" }` | +| `stop_agent` | `{}` | `{ state: "stopped" }` | +| `open_popup_window` | `{ route }` | `{ label }` | +| `close_current_window` | `{}` | `null` | +| `exit_application` | `{}` | `null` | + +`CaptureResult`: + +```json +{ + "screenshotPath": "受限应用数据目录中的路径", + "screenshotWidth": 1440, + "screenshotHeight": 900, + "scaleFactor": 1.5, + "source": { + "id": "win-window:12345", + "kind": "window", + "label": "WeChat", + "appName": "WeChat.exe", + "title": "微信", + "pid": 4312, + "x": 200, + "y": 100, + "width": 960, + "height": 600, + "scaleFactor": 1.5 + }, + "screenListMs": 12, + "captureMs": 40, + "saveMs": 8, + "totalMs": 60 +} +``` + +`AnnotationConfig`: + +```json +{ + "app": "wechat", + "screenshotPath": "受限应用数据目录中的路径", + "screenshotWidth": 1440, + "screenshotHeight": 900, + "scaleFactor": 1.5, + "source": {}, + "regions": [], + "createdAt": "2026-07-21T09:40:00Z", + "updatedAt": "2026-07-21T09:42:00Z", + "revision": 3 +} +``` + +原生保存成功后由 Tauri event 发送: + +```json +{ + "event": "annotation-completion-changed", + "payload": true +} +``` + +引擎状态变化沿用: + +```json +{ + "event": "engine-state-changed", + "payload": { "enabled": true } +} +``` + +标注调试状态只允许 Tauri/Rust 本地模块读取。HTTP 业务服务和 Go 后端不得读取、同步或转发截图、窗口信息及标注区域。 + +--- + +## 15. 页面到接口映射 + +| 页面/窗口 | 源文件 | 初始加载 | 用户操作 | 实时事件 | +|---|---|---|---|---| +| 微信分身 | `src/pages/ClonePage.jsx` | `/bootstrap`、`/engine/state` | 启动检查、启动/停止引擎、暂停自动发送;标注走 Tauri IPC | `engine.*`、`log.appended` | +| 运行日志 | `src/windows/EngineLogsWindow.jsx` | `GET /logs` | 导出日志;清空/暂停是前端视图操作 | `log.appended`、`export.completed` | +| 知识库列表 | `src/pages/KnowledgePage.jsx` | `GET /knowledge-bases` | 搜索、打开详情 | `knowledge.changed` 可选 | +| 新增/更新知识库 | `src/windows/KnowledgeWindows.jsx` | 创建/读取 knowledge draft | 上传文档、图片或视频;预览转换后的 TXT;人工填写媒体描述;移除、发布 | `file.processing.updated`、`knowledge.changed` | +| 知识库详情、预览、引用、版本 | `src/windows/KnowledgeWindows.jsx` | knowledge detail/files/references/versions | 预览、下载、启停、更新、删除 | `knowledge.changed`、文件处理事件 | +| 智能体市场 | `src/pages/SkillsPage.jsx` | `GET /agents` | 搜索、筛选、启停、打开详情 | `agent.changed` 可选 | +| 新增/更新智能体 | `src/windows/SkillWindow.jsx` | 创建/读取 agent draft | 基本信息、提示词、知识授权、测试、发布 | 测试会话事件 | +| 智能体详情 | `src/windows/SkillWindow.jsx` | `GET /agents/{id}` | 更新、测试、启停、删除 | `agent.changed` | +| 测试智能体 | `src/components/AgentChatPanel.jsx` | 创建 test session、加载 messages | 上传附件、发送、取消生成 | `agent.test.*` | +| 员工蒸馏主页 | `src/pages/DistillPage.jsx` | active jobs、employees | 搜索、取消任务、打开详情 | `distill.progress.updated` | +| 开始蒸馏 | `src/windows/DistillWindows.jsx` | 创建/读取 distill draft | 上传文件、开始提取、重新评估、导出、发布 | `file.processing.updated`、`distill.*` | +| 员工详情 | `src/windows/DistillWindows.jsx` | `GET /employees/{id}` | 启停、编辑画像、管理授权、重新蒸馏、复制 | `employee.changed` | +| 设置 | `src/windows/SettingsContent.jsx` | `GET /settings` | 保存/恢复、Key 更换/删除、连接测试、名单 CRUD | `settings.changed` | +| 标注调试页 | `src/pages/AnnotationPage.jsx` | Tauri `capture_screen/load_regions` | 画框、编辑、删除、保存 | Tauri annotation event | +| 原生透明标注层 | `src/overlay.js` | Tauri overlay state | 选窗、画框、编辑、删除、保存或取消 | Tauri annotation event | + +--- + +## 16. 前端交互约束 + +1. 所有页面首次打开必须显示 loading,失败显示真实错误和重试入口;不得用 mock 数据补成功状态。 +2. 按钮发起 mutation 后立即禁用,直到响应完成;重复点击依赖 `Idempotency-Key` 兜底。 +3. 搜索输入建议 250ms debounce;新的请求发出时取消旧请求,避免旧结果覆盖新结果。 +4. 详情窗口更新后关闭原窗口没有问题,但重新打开详情必须 GET 最新资源,不能依赖 `localStorage` 或 `BroadcastChannel` 作为业务事实源。 +5. WS 事件只更新同 revision 或更新 revision;发生缺号或冲突时重新 GET。 +6. 文件选择后先展示本地文件名和上传进度;只有 `FileAsset.status=ready` 才显示“格式有效/可预览”。 +7. 智能体测试期间禁用下一步、附件移除和重复发送;`completed` 后才标记测试通过。 +8. 蒸馏后台运行时关闭窗口不取消任务;再次打开按 jobId 查询并续订频道。 +9. 删除响应成功后从列表移除;`409 RESOURCE_IN_USE` 必须显示引用资源,不得假装删除成功。 +10. 配置保存以服务端返回的完整对象替换本地表单基线;连接测试不自动保存配置。 + +--- + +## 17. 实施顺序 + +1. **基础层**:HTTP client、错误模型、session token、SQLite repository、revision、WebSocket ticket/重连。 +2. **设置与知识库**:消除 API Key、本地配置、知识列表和创建流程中的 mock/localStorage。 +3. **文件中心**:上传、解析、预览、清理和处理进度事件。 +4. **智能体**:草稿、授权、测试 WS 流式输出、发布和详情。 +5. **蒸馏与员工**:后台任务、进度、评估、发布和详情子资源。 +6. **引擎与日志**:将现有 Tauri/sidecar 状态映射成结构化 HTTP/WS 契约,同时保留标注为 Tauri IPC。 + +完成判定:页面展示字段均来自上述响应;所有可操作按钮有真实 mutation;长任务和流式生成有可恢复事件;上传、解析失败、并发冲突和断线重连都有明确结果。 diff --git a/docs/base-ui.css b/docs/base-ui.css new file mode 100644 index 0000000..0944ac0 --- /dev/null +++ b/docs/base-ui.css @@ -0,0 +1,808 @@ +/* + Base UI tokens + reusable component classes + Extracted from the current desktop workbench design language. + Suitable for plain HTML + CSS projects. +*/ + +:root { + color-scheme: light; + + /* colors */ + --bg: #f7f8f7; + --page: #ffffff; + --surface: #ffffff; + --surface-2: #f2f5f3; + --text: #151716; + --muted: #66716c; + --subtle: #909a95; + --line: #dde4df; + --line-strong: #cad5cf; + --primary: #07c160; + --primary-2: #10d978; + --primary-soft: #e6f7ee; + --primary-soft-2: #f1fbf6; + --accent: #2d8cff; + --warning: #fa8c16; + --error: #f5222d; + + /* radii */ + --radius-window: 12px; + --radius-panel: 18px; + --radius-soft: 14px; + --radius-input: 11px; + --radius-pill: 999px; + + /* shadows */ + --shadow-panel: 0 24px 70px rgba(20, 36, 27, 0.1); + --shadow-glass: 0 10px 28px rgba(20, 36, 27, 0.06); + --shadow-primary: 0 10px 28px rgba(7, 193, 96, 0.22); + --shadow-danger: 0 10px 28px rgba(245, 34, 45, 0.22); + --shadow-fab: 0 18px 36px rgba(7, 193, 96, 0.28); + + /* spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + + /* typography */ + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #090d0b; + --page: #0d1110; + --surface: #121817; + --surface-2: #18211e; + --text: #f4f7f5; + --muted: #aab5af; + --subtle: #7f8c85; + --line: #24302b; + --line-strong: #34423c; + --primary: #07c160; + --primary-2: #0ed46e; + --primary-soft: #0d2a1b; + --primary-soft-2: #10251a; + --accent: #2d8cff; + --warning: #fa8c16; + --error: #ff4d4f; + --shadow-panel: 0 28px 80px rgba(0, 0, 0, 0.36); + --shadow-glass: 0 10px 28px rgba(0, 0, 0, 0.3); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +img { + max-width: 100%; + display: block; +} + +.scrollbar-hidden { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollbar-hidden::-webkit-scrollbar { + display: none; +} + +/* shell */ +.app-window { + min-height: 100vh; + background: + radial-gradient(circle at 16% 4%, rgba(7, 193, 96, 0.18), transparent 30%), + radial-gradient(circle at 86% 2%, rgba(45, 140, 255, 0.16), transparent 26%), + radial-gradient(circle at 50% 36%, rgba(7, 193, 96, 0.13), transparent 32%), + linear-gradient(180deg, color-mix(in srgb, var(--page) 58%, transparent), color-mix(in srgb, var(--page) 76%, transparent) 72%); +} + +.app-window.rounded { + overflow: hidden; + border-radius: var(--radius-window); +} + +.page-body { + position: relative; + padding: 20px; +} + +.page-center { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.glass-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 20px; + border-bottom: 1px solid var(--line); + background: + linear-gradient(10deg, color-mix(in srgb, var(--surface) 44%, transparent), color-mix(in srgb, var(--surface) 22%, transparent)); + backdrop-filter: blur(10px) saturate(1.35); + -webkit-backdrop-filter: blur(28px) saturate(1.35); + box-shadow: var(--shadow-glass); +} + +.glass-bar .title { + min-width: 0; + font-size: 18px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 16px; +} + +/* panels */ +.panel { + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: var(--shadow-panel); +} + +.panel-lite { + border-radius: var(--radius-panel); + background: transparent; +} + +.card-pad { + padding: 16px; +} + +.list-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow-panel); +} + +.list-main { + min-width: 0; + flex: 1; +} + +.list-main h3 { + margin: 0; + font-size: 14px; + font-weight: 800; +} + +.list-main p { + margin: 8px 0 0; + font-size: 12px; + color: var(--muted); +} + +.list-meta { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +/* buttons */ +.btn-primary, +.btn-secondary, +.btn-danger, +.btn-ghost, +.icon-action, +.icon-btn, +.fab { + border: 0; + outline: none; + transition: 0.2s ease; +} + +.btn-primary, +.btn-danger, +.btn-secondary { + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 20px; + border-radius: var(--radius-pill); + font-size: 14px; +} + +.btn-primary { + background: var(--primary); + color: #fff; + font-weight: 800; + box-shadow: var(--shadow-primary); +} + +.btn-primary:hover { + background: #05964d; +} + +.btn-secondary { + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + font-weight: 700; +} + +.btn-secondary:hover { + background: var(--surface-2); +} + +.btn-danger { + background: var(--error); + color: #fff; + font-weight: 800; + box-shadow: var(--shadow-danger); +} + +.btn-danger:hover { + background: #cf1322; +} + +.btn-ghost { + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + border-radius: 10px; + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.btn-ghost:hover { + background: var(--surface-2); + color: var(--text); +} + +.icon-btn, +.icon-action { + display: inline-grid; + place-items: center; +} + +.icon-btn { + width: 36px; + height: 36px; + border-radius: 10px; + background: transparent; + color: var(--muted); +} + +.icon-btn:hover { + color: var(--text); +} + +.icon-action { + width: 40px; + height: 40px; + border-radius: var(--radius-pill); + border: 1px solid var(--line); + background: var(--surface); + color: var(--muted); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04); +} + +.icon-action:hover { + background: var(--surface-2); + color: var(--primary); +} + +.fab { + position: fixed; + right: 24px; + bottom: 96px; + width: 56px; + height: 56px; + border-radius: var(--radius-pill); + background: var(--primary); + color: #fff; + font-size: 30px; + line-height: 1; + box-shadow: var(--shadow-fab); +} + +.fab:hover { + background: #05964d; +} + +/* status + tags */ +.status-pill, +.tag { + display: inline-flex; + align-items: center; + border-radius: var(--radius-pill); +} + +.status-pill { + min-height: 24px; + padding: 0 10px; + font-size: 11px; + font-weight: 600; +} + +.status-pill.success { + color: var(--primary); +} + +.status-pill.warning { + color: var(--warning); +} + +.status-pill.muted { + color: var(--muted); +} + +.tag { + padding: 4px 12px; + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--muted); + font-size: 10px; + font-weight: 700; +} + +.tag.tone-green { + border-color: #bfe8d1; + background: #edf9f2; + color: #2f8a57; +} + +.tag.tone-orange { + border-color: #f3d6a6; + background: #fff7e8; + color: #9a651b; +} + +.tag.tone-purple { + border-color: #dfd1f2; + background: #f7f0ff; + color: #7650a8; +} + +html[data-theme="dark"] .tag.tone-green { + border-color: #244735; + background: #102119; + color: #8bd6a8; +} + +html[data-theme="dark"] .tag.tone-orange { + border-color: #4b3720; + background: #241a0e; + color: #e5ba70; +} + +html[data-theme="dark"] .tag.tone-purple { + border-color: #3e3153; + background: #1f182b; + color: #c4a2ef; +} + +.version.warning { + color: var(--warning); + font-size: 12px; + font-weight: 700; +} + +/* form controls */ +.field { + display: block; +} + +.field > span { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 800; + color: var(--muted); +} + +.input-like, +.editor, +.select-like { + width: 100%; + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--text); + outline: none; + transition: border-color 0.2s ease; +} + +.input-like, +.select-like { + height: 42px; + padding: 0 12px; + border-radius: var(--radius-input); + font-size: 13px; + font-weight: 600; +} + +.editor { + min-height: 160px; + padding: 10px; + border-radius: 8px; + resize: none; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + line-height: 1.7; +} + +.input-like:focus, +.select-like:focus, +.editor:focus { + border-color: var(--primary); +} + +.action-row { + display: flex; + gap: 12px; +} + +.action-row.two-col > * { + flex: 1; +} + +/* segmented */ +.segmented-bar { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.seg-item { + flex-shrink: 0; + padding: 6px 32px; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--muted); + font-size: 13px; +} + +.seg-item:hover { + background: var(--surface-2); +} + +.seg-item.is-active { + background: var(--primary); + color: #fff; + box-shadow: 0 10px 24px rgba(7, 193, 96, 0.22); +} + +/* switch */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius-soft); + background: var(--surface); +} + +.mini-switch { + position: relative; + width: 48px; + height: 28px; + flex-shrink: 0; + padding: 3px; + border-radius: var(--radius-pill); + background: var(--line-strong); +} + +.mini-switch span { + display: block; + width: 22px; + height: 22px; + border-radius: var(--radius-pill); + background: #fff; + transition: transform 0.2s ease; +} + +.mini-switch.is-on { + background: var(--primary); +} + +.mini-switch.is-on span { + transform: translateX(20px); +} + +/* terminal */ +.terminal { + overflow: hidden; + padding: 16px; + border: 1px solid var(--line); + border-radius: 10px; + background: + radial-gradient(circle at 12% 0%, rgba(7, 193, 96, 0.08), transparent 30%), + linear-gradient(145deg, #0b100d, #101814 52%, #090f0c); + box-shadow: var(--shadow-panel); + color: #d7ffe4; +} + +.terminal h3 { + margin: 0 0 12px; + font-size: 13px; + font-weight: 800; +} + +.terminal pre, +.terminal code, +.terminal .terminal-body { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.7; +} + +.terminal .line-info { color: #8ff0b3; } +.terminal .line-debug { color: #82c7ff; } +.terminal .line-think { color: #c9a7ff; } +.terminal .line-warning { color: #ffd166; } +.terminal .line-error { color: #ff6b7a; } + +/* special visual modules */ +.engine-sphere { + position: relative; + width: 190px; + height: 190px; + border-radius: var(--radius-pill); + background: + radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.95), transparent 12%), + radial-gradient(circle at 42% 36%, rgba(255, 255, 255, 0.38), transparent 20%), + radial-gradient(circle at 62% 72%, rgba(0, 0, 0, 0.22), transparent 34%), + linear-gradient(145deg, #10d978, #07c160 48%, #047a40); + box-shadow: + inset -22px -28px 54px rgba(0, 0, 0, 0.24), + inset 18px 18px 40px rgba(255, 255, 255, 0.18), + 0 34px 80px rgba(7, 193, 96, 0.28); + animation: floatSphere 4.8s ease-in-out infinite; +} + +.engine-sphere::before, +.engine-sphere::after { + content: ""; + position: absolute; + inset: 18px; + border-radius: var(--radius-pill); + border: 1px solid rgba(255, 255, 255, 0.24); + transform: rotate(-24deg); +} + +.engine-sphere::after { + inset: 42px 18px; + opacity: 0.68; + transform: rotate(22deg); +} + +@keyframes floatSphere { + 0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); } + 50% { transform: translate3d(0, -12px, 0) rotate(8deg); } +} + +/* page templates */ +.home-page, +.list-page, +.settings-content, +.side-feed, +.tool-sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.hero-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 8px 24px; + text-align: center; +} + +.hero-center h1 { + margin: 0; + font-size: 22px; + font-weight: 800; +} + +.hero-center p { + margin: 0; + font-size: 13px; + color: var(--muted); +} + +.hero-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + width: 100%; +} + +.settings-layout { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + min-height: 100vh; +} + +.settings-sidebar { + padding: 12px; + border-right: 1px solid var(--line); + background: var(--surface-2); +} + +.settings-nav-item { + width: 100%; + padding: 12px; + border: 0; + border-radius: 12px; + text-align: left; + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.settings-nav-item:hover { + background: var(--surface); +} + +.settings-nav-item.is-active { + background: var(--primary-soft); + color: var(--primary); +} + +.workbench-grid { + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); +} + +.stage-panel, +.side-feed { + min-height: 0; +} + +.stage-box { + position: relative; + min-height: 420px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: #000; +} + +.tool-layout { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + min-height: 100vh; +} + +.tool-sidebar { + padding: 16px; + border-right: 1px solid var(--line); + background: var(--surface); +} + +.tool-stage { + min-width: 0; + padding: 24px; + background: var(--surface-2); +} + +.canvas-shell { + min-height: calc(100vh - 48px); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 20px; + background: #000; + box-shadow: 0 20px 80px rgba(0, 0, 0, 0.55); +} + +.bottom-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + padding: 8px; + border-top: 1px solid var(--line); +} + +.bottom-tab-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px; + border-radius: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.bottom-tab-item:hover { + background: var(--surface-2); +} + +.bottom-tab-item.is-active { + color: var(--primary); +} + +@media (max-width: 900px) { + .workbench-grid, + .tool-layout, + .settings-layout { + grid-template-columns: 1fr; + } + + .page-center { + padding: 20px; + } +} + +@media (max-width: 640px) { + .engine-sphere { + width: 154px; + height: 154px; + } + + .fab { + right: 22px; + } +} diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8b404c3 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,214 @@ +# 微信AI助手开发文档 + +本文为当前项目总览文档,记录仓库现状、已实现能力、启动边界与后续维护重点。 + +## 项目定位 + +本项目是一个本地桌面端“微信AI助手 / AI微信分身助手”原型,用于在本机选择微信或桌面窗口、标注微信界面区域、实时预览微信窗口、读取聊天内容、生成建议回复,并通过配置控制是否自动点击或发送。 + +当前形态不是已上线生产系统,而是桌面 RPA + 多模态识别 + UI 概念展示的混合原型: + +- 已有实现:微信/桌面窗口选择、截屏、区域标注、标注 JSON 保存/加载、Go sidecar 启停、Go vision stream 实时预览、Go agent dry-run 任务生成、Python 视觉检测与自动化实验脚本。 +- UI/概念展示:知识库、skill 市场、员工蒸馏、部分日志与二级详情窗口主要来自 `src/data/mockData.js` 或 `PlaceholderWindow`,尚未接入真实后端、数据库或持久化业务配置。 +- Go `agent/REAMD.md` 标注为“golang 智能体”,默认 agent 日志为 “Go RPA agent demo”,因此本文把 agent 链路描述为原型/实验能力。 + +运行边界: + +- 桌面主应用由 Tauri 启动,前端嵌入 React/Vite 页面。 +- 视觉与自动化能力依赖本机屏幕/窗口权限、可见微信窗口、`agent/config.toml`、`wechat_vision/best.onnx` 和本地 HTTP 服务 `127.0.0.1:8765`。 +- 当前工作站是 Windows;Tauri 标注链路已有 Windows 原生 `EnumWindows` 兜底枚举,并可通过桌面截图裁剪完成窗口标注截图。Go sidecar 已存在 Windows 打包产物 `agent/agent-x86_64-pc-windows-msvc.exe`,但 Python 视觉窗口捕获脚本仍偏 macOS 实现(Quartz、avfoundation、macOS private API 相关配置),Windows 自动化视觉脚本仍需另行适配或验证。 + +## 技术栈 + +| 层级 | 技术/依赖 | 现状与证据 | +| --- | --- | --- | +| 前端 | React 19、React DOM 19、Vite 7、Tailwind CSS 3、PostCSS、Autoprefixer、Lucide React、Radix Select | 来自 `package.json` 的 dependencies:`react ^19.0.0`、`react-dom ^19.0.0`、`vite ^7.0.0`、`tailwindcss ^3.4.17`、`postcss ^8.5.0`、`autoprefixer ^10.4.20`、`lucide-react ^1.17.0`、`@radix-ui/react-select ^2.3.0`。 | +| 桌面壳 | Tauri 2.11、Rust 2021、`tauri-plugin-shell`、`tauri-plugin-log`、`screenshots`、`xcap`、`image`、`windows-sys` | 来自 `src-tauri/Cargo.toml`:`edition = "2021"`,`tauri = "2.11.2"`,`tauri-plugin-shell = "2.3.5"`,`tauri-plugin-log = "2"`,`screenshots = "0.8"`,`xcap = "0.6"`,`image = "0.25"`,`windows-sys = "0.61.2"`。 | +| Tauri 配置 | 产品名、窗口、sidecar、asset protocol | 来自 `src-tauri/tauri.conf.json`:`productName = "微信A助手"`,主窗口标题 `微信AI助手`,尺寸 `500x900`,`decorations = false`,`transparent = true`,`externalBin = ["../agent/agent"]`,asset scope 为 `$APPDATA/data/screenshots/**`。 | +| Go agent | Go module `agent`、Go `1.26.1`、`github.com/pelletier/go-toml/v2` | 来自 `agent/go.mod`:`module agent`,`go 1.26.1`,依赖 `github.com/pelletier/go-toml/v2 v2.2.4`。 | +| Python 视觉/自动化 | `numpy`、`Pillow`、`onnxruntime`、`Quartz`、`pyautogui`、`pyperclip`、标准库 HTTP server/threading/subprocess | 来自 `wechat_vision/*.py` imports。仓库未提供 Python 依赖清单;本文只记录现状,不新增 `requirements.txt`。 | +| 模型/视觉 | ONNX 检测模型、FFmpeg/avfoundation 实时流实验 | `wechat_vision/best.onnx` 是检测模型;`ffmpeg_realtime_detect.py` 使用 FFmpeg `avfoundation`;`demos/go-ffmpeg-wechat-stream/README.md` 标注 macOS only。 | +| LLM/服务 | 火山方舟(Volcengine Ark)/豆包兼容 OpenAI API | 来自 `agent/config.example.toml`:默认 `base_url = "https://ark.cn-beijing.volces.com/api/v3"`,模型 `doubao-seed-2-0-lite-260428`。Go agent 和 Python algorithm live 均读取/复用该配置思路。 | + +## 代码结构与模块职责 + +- `src/`:React UI。入口 `src/main.jsx` 渲染 `src/App.jsx`;`App` 根据 hash 在主壳、标注页、二级窗口之间切换;`MainShell` 渲染微信分身、知识库、skill 市场、员工蒸馏四个底部 Tab。 +- `src/pages/ClonePage.jsx`:微信分身首页。负责启动/停用 agent 与 vision stream,并通过 `enter_window_select_mode` 进入标注;调用 Tauri 命令 `start_vision_stream`、`start_agent`、`stop_agent`、`stop_vision_stream`、`load_regions`。 +- `src/pages/AnnotationPage.jsx`:截屏与全屏标注页。支持矩形区域创建、区域类型/描述编辑、区域保存/加载;保存字段包括 `bbox_image`、`bbox_source`、`bbox_screen`、`scaleFactor`;调用 Tauri 命令 `capture_screen`、`load_regions`、`save_regions`。 +- `src/windows/SecondaryWindow.jsx`:二级窗口路由。承载设置、日志及知识库/Skill/员工蒸馏占位窗口。 +- `src-tauri/src/lib.rs`:Tauri 命令层。管理 `AgentProcess` 与 `VisionStreamProcess`,启动 sidecar `agent` 和 `agent vision-stream`,提供截屏、窗口/桌面来源枚举、区域 JSON 持久化、弹窗创建。 +- `agent/`:Go RPA agent。`main.go` 支持默认 run-once、`vision-stream`、`ax-probe`、`sync-chat`;默认 run-once 读取 `config.toml`、加载 `data/regions/wechat.json` 与 `data/screenshots/WeChat.jpg`、推断缺失区域类型、请求 LLM、保存 latest/history task,最后日志明确“未真实执行鼠标键盘”。`sync-chat` 会基于 `chat_content` 标注区域滚动、截图、调用 LLM 抽取当前页聊天,并保存到 `data/chats/wechat_chat_records.json`。 +- `agent/vision_stream.go`:Go 实时预览服务。提供 `go-window-capture` 来源、JPEG 帧、`latest.json` 状态和事件数据;当前前端不再包含独立工作台消费页面。 +- `wechat_vision/`:Python 视觉实验/自动化。`app.py` 用本地 `best.onnx` 对 `wechat_vision/test` 图片做离线推理并输出标注图/CSV;`ffmpeg_realtime_detect.py` 用 FFmpeg avfoundation + ONNX 检测输入框;`wechat_window_live.py` 用 Quartz 找微信窗口并捕获/检测/提供 MJPEG;`wechat_algorithm_live.py` 基于标注区域监测聊天变化、可调用 LLM、可自动点击徽标并粘贴/发送回复;shell 脚本负责后台启动/停止。 +- `demos/go-ffmpeg-wechat-stream/`:独立 Go FFmpeg 微信窗口流 demo,作为实验/demo 存在,不属于主启动链路。 + +## 核心流程 + +### 区域标注流程 + +1. 主界面点击“开始标注”。 +2. `ClonePage.startAnnotation` 调用 `list_capture_sources`,Tauri 返回 display/window 来源列表。 +3. 用户选择桌面或应用窗口后,`ClonePage.beginAnnotationWithSource` 调用 `capture_source`。 +4. Tauri 将截图保存到 `$APPDATA/data/screenshots/WeChat.jpg`,并把截图路径、尺寸、scale factor、来源信息返回前端。 +5. 标注页打开 `/annotate`,用户拖拽绘制区域,编辑区域名称、类型和用途描述。 +6. `AnnotationPage.save` 组装 annotation,区域字段包含 `bbox_image`、`bbox_source`、`bbox_screen`、`scaleFactor`。 +7. `save_regions` 保存 `$APPDATA/data/regions/wechat.json`;后续 `load_regions` 可回读。 + +### 引擎启动流程 + +1. 主界面点击“启动引擎”。 +2. `ClonePage.toggleAgent` 先调用 `start_vision_stream`,启动 Go sidecar `agent vision-stream`。 +3. 前端再调用 `start_agent`,启动默认 Go run-once agent。 +4. 如果 agent 启动失败,前端回滚并调用 `stop_vision_stream`,随后进入错误状态。 +5. 两个进程启动成功后,主页面状态切换为运行中并记录启动日志。 +6. 用户停用引擎时,依次调用 `stop_agent`、`stop_vision_stream`,主页面状态切换为停止。 + +### Go agent run-once 流程 + +1. `agent/main.go` 默认进入 `runOnce()`;子命令 `vision-stream`、`ax-probe`、`sync-chat` 分别走独立流程。 +2. `runOnce()` 读取 `agent/config.toml`;缺失时返回“读取 config.toml 失败”。 +3. `loadConfig` 应用默认值:`app_data_dir` 默认 `~/Library/Application Support/com.tauri.dev`,regions/screenshot/tasks 路径默认位于 `data/regions/wechat.json`、`data/screenshots/WeChat.jpg`、`data/tasks/latest_task.json`、`data/tasks/history`,聊天同步默认输出 `data/chats/wechat_chat_records.json`。 +4. 根据 `app_data_dir` 定位标注文件和截图,读取区域并汇总。 +5. 对缺失区域类型做启发式推断;要求至少存在或推断出 `chat_content` 与 `input_box`。 +6. 如果 `observe_mode` 需要滚动观察,会按配置滚动聊天区域后再读取截图。 +7. 读取 `WeChat.jpg`,构造 prompt,请求豆包兼容多模态模型识别聊天内容并生成任务计划。 +8. 解析 LLM 返回的 task JSON,调用 `finalizeTask` 补齐区域、截图、dry-run 等信息。 +9. 保存 `latest_task.json` 与 history task 文件。 +10. 默认 `dry_run = true`,且 `runOnce()` 结束日志为“Go RPA agent demo 完成,未真实执行鼠标键盘”。 + +### Python algorithm live 实验流程 + +1. `wechat_vision/start_wechat_algorithm_live.sh` 后台执行 `./venv/bin/python wechat_algorithm_live.py`。 +2. 默认参数包括 `--fps 30`、`--host 127.0.0.1`、`--port 8765`、`--click-on-badge`、`--llm-on-click`、`--llm-on-chat-change`、`--reply-on-chat-change`、`--send-reply`。 +3. `wechat_algorithm_live.py` 默认读取 `~/Library/Application Support/com.tauri.dev/data/regions/wechat.json` 与 `agent/config.toml`。 +4. 脚本复用 `wechat_window_live.py` 的 `find_wechat_window`、`capture_window_image` 等能力,按标注区域监测聊天内容变化。 +5. 触发后可调用 LLM 读取当前聊天、生成 `reply_text`,并通过 `pyautogui`/`pyperclip` 点击、粘贴、按 Enter。 +6. 该流程是高风险实验能力:虽然脚本参数里 `--dry-run` 默认值存在,但启动脚本同时传入了点击与发送相关参数;执行前必须确认 dry-run 行为、系统权限、目标微信窗口和发送策略,避免误点或误发。 + +补充:Go `sync-chat` 子命令会读取相同配置和标注,首次同步时先滚动到聊天顶部,再按 `chat_sync_max_pages` 逐页截图与调用 LLM 抽取聊天消息,去重后写入 `chat_records_path`。 + +## 已完成进度 + +### 已完成/已有代码 + +- Tauri 桌面壳与透明无边框主窗口。 +- 主 UI 四个 Tab:微信分身、知识库、skill 市场、员工蒸馏。 +- 设置、知识库、skill、员工蒸馏等二级窗口框架。 +- 主题切换与跨窗口 localStorage 同步。 +- 截屏来源枚举:桌面 display 与可见窗口 window。 +- 桌面/窗口截图保存到 Tauri app data。 +- 区域标注、区域类型/描述编辑、区域 JSON 保存/加载。 +- Go sidecar 启停:`start_agent` / `stop_agent`。 +- Go vision stream 启停:`start_vision_stream` / `stop_vision_stream`。 +- 引擎工作台实时帧展示、FPS 展示、区域 overlay、聊天读取结果与建议回复展示。 +- Go agent dry-run 任务生成链路:读取配置、读取标注与截图、请求 LLM、保存 latest/history task。 +- Python ONNX 检测与 MJPEG 预览脚本。 +- Python algorithm live 自动化实验脚本。 +- Go FFmpeg 微信窗口流 demo。 +- Go `sync-chat` 聊天记录同步 demo:滚动 `chat_content` 区域、抽取聊天页、去重保存本地聊天归档。 + +### 仅 UI/Mock 或未接真实后端 + +- 知识库列表、skill 市场、员工蒸馏数据来自 `src/data/mockData.js`。 +- 日志面板部分使用 mock `logs`。 +- 知识库创建/详情、skill 创建、员工详情、蒸馏开始是 `PlaceholderWindow` 占位内容。 +- 设置页是表单/开关展示,没有看到持久化配置写入逻辑。 +- 前端没有测试文件覆盖关键页面或 Tauri 调用链路。 + +## 开发与启动说明 + +### Node/Tauri + +`package.json` 提供以下 npm scripts: + +```bash +npm run dev # vite --host 127.0.0.1 +npm run tauri # tauri +npm run build # vite build +npm run preview # vite preview --host 127.0.0.1 +``` + +说明:本文档任务不要求安装依赖或运行构建;以上命令仅记录仓库脚本现状。 + +### Tauri 配置 + +- 产品名:`微信A助手`。 +- 窗口标题:`微信AI助手`。 +- 主窗口:`500x900`,最小 `500x900`,透明、无边框、可调整尺寸。 +- devUrl:`http://127.0.0.1:5173`。 +- beforeDevCommand:`npm run dev`。 +- beforeBuildCommand:`npm run build`。 +- bundle externalBin:`../agent/agent`。 +- asset protocol scope:`$APPDATA/data/screenshots/**`。 +- app identifier 当前仍为 `com.tauri.dev`。 +- `macOSPrivateApi` 已启用。 + +### Go agent + +1. 复制配置: + +```bash +cp agent/config.example.toml agent/config.toml +``` + +2. 填写火山方舟/豆包 API key: + +```toml +[volcengine] +base_url = "https://ark.cn-beijing.volces.com/api/v3" +api_key = "YOUR_ARK_API_KEY" +model = "doubao-seed-2-0-lite-260428" +``` + +3. 默认 agent 配置重点: + +```toml +[agent] +app_data_dir = "~/Library/Application Support/com.tauri.dev" +regions_path = "data/regions/wechat.json" +screenshot_path = "data/screenshots/WeChat.jpg" +task_output_path = "data/tasks/latest_task.json" +task_history_dir = "data/tasks/history" +dry_run = true +observe_mode = "normal" +max_scrolls = 1 +scroll_delta_y = 6 +chat_records_path = "data/chats/wechat_chat_records.json" +chat_sync_max_pages = 3 +chat_sync_top_scrolls = 8 +``` + +4. `runOnce()` 默认读取当前工作目录下的 `config.toml`;如果从 `agent/` 目录外启动,需要注意工作目录和配置文件位置。 +5. `config.go` 当前会强制 `config.Agent.DryRun = true`,因此 Go run-once 链路按现状不会真实执行鼠标键盘。 + +### Python 视觉 + +- 主要入口: + - `wechat_vision/app.py`:ONNX 模型离线推理验证,读取 `wechat_vision/test`,输出标注图片与 `coordinates.csv` 到 `wechat_vision/ouptsw`。 + - `wechat_vision/ffmpeg_realtime_detect.py`:FFmpeg avfoundation + ONNX 输入框检测实验。 + - `wechat_vision/wechat_window_live.py`:Quartz 微信窗口捕获、检测、MJPEG/HTTP 预览。 + - `wechat_vision/wechat_algorithm_live.py`:基于标注区域的聊天变化监测、LLM 读取、自动点击/回复实验。 + - `wechat_vision/start_wechat_algorithm_live.sh` / `stop_wechat_algorithm_live.sh`:后台启动/停止 algorithm live。 +- 启动脚本默认使用 `./venv/bin/python`,本地需自行准备 venv。 +- 本地需自行准备 Python 依赖、FFmpeg/ffplay、屏幕录制权限、辅助功能权限、微信可见窗口。 +- 仓库没有 Python 依赖清单;不要把当前 Python 脚本当作跨平台即插即用能力。 + +## 待完善事项 + +- Python 没有依赖清单,缺少 `requirements.txt` 或等价环境说明。 +- 仓库未发现自动化测试;前端关键页面、Tauri 命令、Go agent、Python 视觉链路均缺少测试覆盖。 +- Go `runOnce()` 默认要求 `agent/config.toml` 或当前工作目录下 `config.toml` 存在,否则直接失败。 +- Python 视觉脚本偏 macOS Quartz/avfoundation;Tauri 标注截图已补充 Windows 原生窗口枚举/裁剪兜底,但 Windows 上的 Python 自动化视觉流程仍需适配/验证。 +- Tauri app identifier 仍为 `com.tauri.dev`,发布前应替换为正式 identifier。 +- `agent/REAMD.md` 文件名疑似 `README.md` 拼写错误;本次文档任务不改名。 +- 知识库、skill 市场、员工蒸馏需要接入真实数据源、业务 API 或本地持久化后,才能从 UI/概念展示升级为真实功能。 +- 设置页配置项需要明确写入目标:Tauri store、文件、数据库或 agent config,目前仅看到表单展示。 +- Python algorithm live 的真实点击/发送能力需要增加更明确的安全门禁、dry-run 可视化确认和操作日志。 + +## 维护建议 + +- 保持本文档是现状说明,不把 mock UI 写成生产功能。 +- 后续若新增真实后端、数据库或自动执行能力,同步更新 `docs/development.md` 的“已完成进度”和“核心流程”。 +- 涉及自动点击/发送的改动必须在文档中标注风险、默认行为、开关位置和验证方式。 +- Tauri 命令、Go agent 配置、Python 脚本参数发生变化时,同步更新“代码结构与模块职责”和“开发与启动说明”。 +- 如果以后引入项目记忆系统,可另建 `.memory/`;本次不创建 `.memory/`,因为本次交付范围固定为 `/docs/development.md`。 diff --git a/docs/go-backend-development-plan.md b/docs/go-backend-development-plan.md new file mode 100644 index 0000000..1610883 --- /dev/null +++ b/docs/go-backend-development-plan.md @@ -0,0 +1,1068 @@ +# 微信 AI 助手 Go 后端与多智能体开发方案 + +> 本文基于 `docs/api-design.md` 的目标契约制定,供技术评审和实施拆分使用。 +> +> 当前 API 文档是目标契约,不代表接口已经实现;当前 `agent/` 目录是单次运行的 RPA demo,不应直接作为生产后端继续堆功能。 + +## 1. 评审结论 + +推荐采用“模块化单体 Go sidecar + 本地原生自动化宿主 + 受控多智能体运行时”的架构: + +- Go sidecar 只监听 `127.0.0.1:8766`,承载 HTTP、WebSocket、SQLite、文件中心、模型网关、多智能体调度、知识库、蒸馏、员工和审计日志。 +- Tauri/Rust 继续独占窗口选择、截图、坐标、标注、鼠标键盘和 Windows 原生生命周期。 +- 多智能体首期运行在同一个 Go 进程内,不拆微服务,不引入 Redis、Kafka、Temporal 或 Kubernetes。 +- SQLite 是唯一业务事实源;文件使用应用数据目录下的内容寻址存储;Windows Credential Manager 保存模型密钥。 +- 智能体采用 Supervisor + 有限 DAG/状态机,不允许子 Agent 自由递归、自主扩权或直接执行桌面动作。 +- LLM/VLM 是模型能力提供者;业务 Agent 才负责目标、状态、权限和流程。代码层应将两者分开,避免把一次模型调用误当成完整 Agent。 + +此方案适合当前“单机 Windows、单操作者、本地 sidecar”的产品边界。将来如果要多账号、多设备或云端协作,应另行设计,不在首期预埋分布式复杂度。 + +## 2. 当前代码判断 + +当前 `agent/` 的实际状态: + +- `main.go` 是命令行单次运行入口,不是常驻 HTTP/WS 服务。 +- `config.go` 直接读取 `config.toml`,并且默认路径仍包含 macOS `~/Library/Application Support`。 +- `llm.go`/`planner.go` 直接调用单一兼容 OpenAI 的模型,缺少 provider 抽象、限流、重试分类、结构化输出校验和调用审计。 +- `task.go` 只有 `click/type_text/press_enter/scroll/wait` 等简单动作白名单,任务和历史保存为 JSON 文件。 +- `chat_sync.go` 直接读取标注文件和截图,再调用多模态模型提取聊天。这与当前 API 文档“标注调试数据不进入 Go 后端”的边界不能直接兼容。 +- Go 模块当前只有 TOML 依赖,没有 HTTP、SQLite、WebSocket、迁移、后台任务或测试基础设施。 +- 当前代码强制 `DryRun=true`,真实 Windows 自动化链路尚不能视为完成。 + +因此建议新建正式服务结构,逐步迁移可复用代码;不要直接把所有接口继续写进现有 `package main`。 + +## 3. 编码前必须修正的契约缺口 + +`api-design.md` 可以覆盖当前页面,但要实现多智能体仍有以下缺口。建议先在 API 文档中确认,再冻结 OpenAPI。 + +### 3.1 本地标注边界与引擎接口存在冲突 + +API 同时规定: + +- 标注调试状态、截图、区域和窗口信息不得进入 Go; +- `/bootstrap` 和 `/engine/state` 又包含 `annotationComplete`; +- `/engine/startup-checks` 包含 annotation check; +- Go WebSocket 事件表包含 `engine.annotation.changed`。 + +推荐修正: + +1. `annotationComplete` 由 Tauri 前端通过本地 IPC 合并展示,不由 Go 持久化。 +2. `engine.annotation.changed` 改为 Tauri event,不进入 Go WebSocket。 +3. Rust 在启动生产引擎前完成本地检查,仅向 Go 提交一次性 `nativeReadinessToken` 或布尔能力证明;Go 不接收标注内容。 +4. Go 的启动检查只负责模型、配置、知识库、任务队列等业务检查;窗口和标注检查由 Rust 返回。 + +### 3.2 单模型设置不足以支持 LLM/VLM + +现有 `/settings` 只有一个 `model`。多模型至少需要: + +- `modelProfiles[]`:`id`、`kind=llm|vlm|embedding`、provider、endpoint、modelName、超时、并发、能力标签; +- 每个 Agent 引用 `modelProfileId`,而不是读取全局唯一模型; +- 密钥按 provider/profile 保存,但响应只返回 `keyConfigured`。 + +首期也可以只有一个 LLM profile 和一个 VLM profile,但数据结构不能把二者写死成同一个模型。 + +### 3.3 Agent 对象缺少运行类型和工具策略 + +现有 `AgentSummary.type=builtin|custom` 只是来源类型。建议补充: + +- `agentKind`:`conversation | llm_worker | vlm_worker | wechat_action | chat_reader | employee_distill | knowledge_summary | knowledge_entity`; +- `capabilities[]`:可被 Supervisor 路由的能力; +- `modelProfileId`; +- `toolPolicy`:允许工具、最大步骤、超时、是否必须人工确认; +- `inputSchemaVersion`、`outputSchemaVersion`; +- `runtimeVersion`:用于重放和问题定位。 + +`builtin|custom` 保留为 `sourceType` 更清晰。 + +### 3.4 VLM 测试入口不足 + +当前 `agent_test_attachment` 不接受图片和视频。如果产品需要在智能体测试窗口验证 VLM,应补充图片格式;视频首期建议只接受明确抽取后的若干用户选择帧,避免隐式视频理解。 + +知识库图片/视频仍必须遵守现有规则:只使用人工描述,不允许 Knowledge Agent 或 VLM 自动生成描述。 + +### 3.5 缺少 Agent Run 可观测接口 + +建议增加只读诊断接口: + +- `GET /agent-runs/{runId}`:运行状态、开始/结束时间、最终结果和错误; +- `GET /agent-runs/{runId}/steps`:Agent 步骤、工具名、耗时、token、结果摘要; +- `POST /agent-runs/{runId}/cancel`:取消仍在运行的测试或后台任务。 + +不得返回完整系统提示词、API Key、未脱敏聊天原文或模型内部推理文本。 + +## 4. 总体架构 + +```mermaid +flowchart LR + UI[React UI] -->|HTTP/WS 8766| API[Go API Server] + UI -->|Tauri invoke/event| Native[Tauri/Rust Native Host] + + API --> APP[Application Services] + APP --> AR[Multi-Agent Runtime] + APP --> JOB[Durable Job Runner] + APP --> DB[(SQLite + FTS5)] + APP --> FS[Content-addressed File Store] + APP --> EVT[Transactional Outbox + WS Hub] + + AR --> MG[Model Gateway] + AR --> TOOL[Tool Registry] + MG --> LLM[LLM Providers] + MG --> VLM[VLM Providers] + + Native -->|Sanitized native bridge| BRIDGE[Local Agent Bridge] + BRIDGE --> AR + AR -->|Semantic action plan| BRIDGE + Native --> WX[WeChat Window] +``` + +### 4.1 进程职责 + +#### React/Tauri 前端 + +- 页面交互、loading/error、表单 dirty 状态和本地主题; +- 通过 HTTP/WS 访问 Go 业务服务; +- 通过 Tauri IPC 使用标注、窗口选择和本地生命周期能力; +- 不把 mock/localStorage 当作业务事实源。 + +#### Tauri/Rust Native Host + +- 启停并监督 Go sidecar; +- 生成 session token,并通过继承管道或受限文件句柄安全传递; +- 独占标注文件、截图、窗口句柄、DPI、区域坐标和鼠标键盘执行; +- 把生产运行期观察结果转换为不含坐标、截图路径和窗口句柄的语义对象; +- 校验 Go 返回的动作计划,在必要时请求人工确认,再执行动作; +- 保持标注调试链路完全不进入 Go。 + +#### Go Business/Agent Sidecar + +- 提供 `api/v1` HTTP 和 WebSocket; +- 管理设置、文件、知识库、Agent、蒸馏、员工、日志和导出; +- 运行模型网关和多智能体调度; +- 保存结构化运行记录和业务状态; +- 不直接调用 Windows 鼠标键盘 API,不读取标注文件。 + +### 4.2 Native Agent Bridge + +生产自动化不建议复用公开 HTTP。使用 Tauri 启动 Go 时建立的匿名管道或当前用户受限的 Windows named pipe,传输长度前缀 JSON 消息。 + +Rust 到 Go 的最小语义消息: + +```json +{ + "type": "conversation.observed", + "observationId": "obs_01...", + "conversationFingerprint": "sha256:...", + "messages": [ + { + "messageFingerprint": "sha256:...", + "role": "other", + "content": "请问可以退款吗?", + "occurredAt": null + } + ], + "observedAt": "2026-07-21T09:35:01Z" +} +``` + +Go 到 Rust 的语义动作计划: + +```json +{ + "type": "wechat.action.plan", + "planId": "plan_01...", + "conversationFingerprint": "sha256:...", + "expectedObservationId": "obs_01...", + "actions": [ + { "type": "focus_input" }, + { "type": "replace_text", "text": "可以,请提供订单号。" }, + { "type": "request_send", "confirmationRequired": true } + ], + "expiresAt": "2026-07-21T09:35:11Z" +} +``` + +关键约束: + +- Go 只使用语义目标,不下发屏幕坐标;Rust 根据当前本地标注解析目标。 +- 动作计划必须绑定 conversation fingerprint 和 observation ID,防止窗口切换后误发。 +- 计划短时过期;执行前重新检查窗口、会话和输入框状态。 +- `request_send` 与“是否自动发送”分离;最终策略由 Rust 和托管设置共同裁决。 +- Bridge 不传输标注截图、标注坐标、窗口句柄或标注文件路径。 + +## 5. Go 工程结构 + +推荐目录: + +```text +agent/ + cmd/wechat-ai-sidecar/main.go + internal/ + bootstrap/ # 依赖装配、启动和优雅关闭 + api/ + httpapi/ # route、handler、middleware、DTO + realtime/ # ticket、WS hub、订阅和续传 + app/ # command/query application services + domain/ + engine/ + files/ + knowledge/ + agents/ + distill/ + employees/ + settings/ + logs/ + agentruntime/ + supervisor/ + registry/ + runner/ + tools/ + policies/ + trace/ + agents/ + conversation/ + llmworker/ + vlmworker/ + wechataction/ + chatreader/ + employeedistill/ + knowledgesummary/ + knowledgeentity/ + model/ + gateway/ + openaicompat/ + volcengine/ + nativebridge/ + jobs/ + persistence/ + sqlite/ + migrations/ + filestore/ + secrets/ + parsing/ + txt/ + pdf/ + docx/ + pptx/ + observability/ + migrations/ + testdata/ +``` + +规则: + +- `domain` 不依赖 HTTP、SQLite 或具体模型 SDK。 +- handler 只负责协议解析、鉴权和 DTO 映射,业务事务放在 `app`。 +- repository 接口定义在使用方模块,SQLite 实现在 `persistence/sqlite`。 +- Agent 只能通过 Tool Registry 使用受控能力,不能直接访问数据库或任意文件路径。 +- 模型 provider 只实现模型调用,不承载业务 prompt。 + +## 6. 建议技术选型 + +| 能力 | 建议 | 原因 | +|---|---|---| +| HTTP | Go `net/http` + `chi` | 中间件和路由清晰,不引入重框架 | +| WebSocket | `coder/websocket` | API 简洁,支持 context 取消 | +| SQLite | `database/sql` + `modernc.org/sqlite` | Windows 构建避免 CGO;实施前必须实测 FTS5 | +| 迁移 | 内嵌 SQL migrations + migration table | 单机服务简单、可审计,避免运行时外部文件依赖 | +| 日志 | `log/slog` JSON handler | 标准库、结构化、方便敏感字段过滤 | +| ID | ULID | 可排序、适合 cursor 和本地日志 | +| 配置 | 业务配置存 SQLite;启动参数仅包含端口和路径 | 避免 TOML/DB 两个事实源 | +| Secret | `SecretStore` 接口 + Windows Credential Manager | API Key 不进 SQLite、日志和普通配置文件 | +| 模型协议 | 自建窄接口 + OpenAI-compatible adapter | 避免业务层被特定 SDK 数据结构绑定 | +| 后台任务 | SQLite durable jobs + 固定 worker pool | 单机足够,可恢复,无需外部队列 | +| 文件 | 应用目录内容寻址存储 + SQLite metadata | 去重、原子完成、便于引用计数和清理 | + +不建议首期引入通用 Agent 框架。多智能体的状态、权限、重放和审计是本项目核心,使用显式 Go 接口和状态机更可控。 + +## 7. HTTP 接口实现方式 + +### 7.1 通用请求链 + +每个请求依次经过: + +1. loopback 来源检查; +2. Bearer session token 鉴权; +3. request ID 生成或透传; +4. body 大小和 Content-Type 限制; +5. JSON 严格解码,拒绝未知字段; +6. handler DTO 校验; +7. application command/query; +8. SQLite 事务和 revision 校验; +9. outbox 事件写入; +10. 标准成功或错误包络。 + +错误必须使用稳定 code,内部错误原因只写脱敏日志,不直接返回堆栈。 + +### 7.2 幂等性 + +`idempotency_keys` 保存: + +- key、route、requestBodyHash; +- responseStatus、responseBody; +- resourceId、createdAt、expiresAt。 + +处理流程: + +- 在事务内锁定/插入 key; +- 相同 key、相同 body 返回首次结果; +- 相同 key、不同 body 返回 `409 IDEMPOTENCY_CONFLICT`; +- 24 小时后后台清理。 + +长任务创建必须先落 job 和幂等记录,再返回 `202`,避免响应丢失后重复创建任务。 + +### 7.3 Revision + +所有可变聚合根包含整数 `revision`。更新 SQL 必须使用: + +```sql +UPDATE agents +SET enabled = ?, revision = revision + 1, updated_at = ? +WHERE id = ? AND revision = ?; +``` + +`RowsAffected=0` 后重新查询:不存在返回 404,存在则返回 `REVISION_CONFLICT`。 + +### 7.4 Cursor + +cursor 使用 base64url 编码的服务端签名结构,例如: + +```json +{ "sortValue": "2026-07-21T09:35:01Z", "id": "agent_01...", "sort": "updatedAt:desc" } +``` + +客户端不得解析;查询使用稳定二元排序 `(updated_at, id)`,避免翻页重复或遗漏。 + +## 8. SQLite 与文件存储 + +### 8.1 核心表 + +建议按聚合建立: + +- 基础:`schema_migrations`、`settings`、`idempotency_keys`、`realtime_tickets`、`events_outbox`、`logs`; +- 文件:`files`、`file_uploads`、`file_upload_parts`、`file_references`; +- 知识库:`knowledge_bases`、`knowledge_drafts`、`knowledge_draft_files`、`knowledge_versions`、`knowledge_version_files`、`knowledge_chunks`、`knowledge_fts`; +- Agent:`agents`、`agent_drafts`、`agent_releases`、`agent_knowledge_bindings`、`agent_test_sessions`、`agent_messages`、`agent_test_records`; +- 运行时:`agent_runs`、`agent_steps`、`agent_tool_calls`、`agent_usage`; +- 蒸馏:`distill_drafts`、`distill_draft_files`、`distill_jobs`、`distill_stages`、`distill_evaluations`; +- 员工:`employees`、`employee_versions`、`employee_agent_bindings`、`employee_knowledge_bindings`、`employee_calls`; +- 对话:`conversations`、`conversation_messages`、`conversation_memories`。 + +### 8.2 SQLite 配置 + +启动时设置并校验: + +- `journal_mode=WAL`; +- `foreign_keys=ON`; +- 合理的 `busy_timeout`; +- 单写连接或应用层串行写入; +- 读连接池数量受控; +- 每次迁移前创建可恢复备份。 + +不依赖 SQLite 自动清理文件引用。发布、删除和草稿清理都在显式事务内维护引用关系。 + +### 8.3 文件中心 + +存储布局建议: + +```text +app-data/ + files/sha256/ab/cd/ + files/converted/.txt + uploads//.part + exports/. +``` + +流程: + +1. 上传写入临时文件,同时流式计算 SHA-256,不把完整文件读入内存; +2. 同时校验扩展名、MIME 和 magic bytes; +3. fsync 后原子 rename 到内容寻址路径; +4. 事务更新 FileAsset; +5. 文档投递 parser job;媒体直接进入 `parseStatus=not_required`; +6. 分片完成时逐片复核哈希和总长度,再合并并校验整体哈希; +7. Range 下载严格处理 `206/416`、Content-Range 和最大并发; +8. 清理任务删除过期 upload、无引用测试附件和孤立导出文件。 + +### 8.4 文档解析 + +首期可行实现: + +- TXT:BOM 检测、UTF-8 校验、换行归一化; +- PDF:使用支持文本层提取的库或隔离命令,只读取文本对象; +- DOCX:按 ZIP/XML 规范读取段落和表格,不执行宏; +- PPTX:按 ZIP/XML 读取文本框、表格和备注; +- 旧 `.ppt`:Go 原生解析生态不足,建议通过受限的 LibreOffice headless 转换到 PPTX/TXT;若产品不接受外部依赖,应从首期支持列表移除 `.ppt`。 + +所有解析器必须有超时、文件大小限制、压缩炸弹限制和临时目录隔离。解析失败只影响该文件 job,不能拖垮 API 进程。 + +## 9. WebSocket 与后台任务 + +### 9.1 WebSocket + +- `POST /realtime-tickets` 在 SQLite 中创建 30 秒单次票据; +- upgrade 时事务性消费票据,防止重放; +- 每个连接维护允许频道集合,不允许订阅任意资源 ID; +- 业务事务同时写聚合数据和 `events_outbox`; +- publisher 从 outbox 发到内存 WS hub,并标记 dispatched; +- 最近 10 分钟事件保留,用 `lastEventId` 续传; +- 续传窗口外返回 `resumeAccepted=false`,客户端重新 GET。 + +模型 token delta 是高频临时事件,可直接流式发送并定期落消息快照;完成、失败和取消事件必须持久化。 + +### 9.2 Durable Job Runner + +`jobs` 至少保存: + +- id、type、state、payloadRef; +- attempt、maxAttempts、availableAt; +- leaseOwner、leaseUntil; +- progress、stage、cancelRequested; +- lastErrorCode、createdAt、updatedAt。 + +worker 通过短租约 claim job。进程崩溃后租约过期可恢复。重试只针对网络暂时失败、模型 429/5xx 和可恢复文件错误;校验错误、权限错误和结构化输出长期不合法不可无限重试。 + +建议并发池: + +- 文件解析:2; +- LLM:按 provider/profile 限制; +- VLM:1,避免本机内存和上游限流; +- 蒸馏:1 个 job,但 job 内可小批量并发; +- 导出:1。 + +## 10. 多智能体运行时 + +### 10.1 核心原则 + +- Supervisor 唯一负责路由和结束条件;子 Agent 不得自由调用任意子 Agent。 +- 每次运行都有 `runId`、输入 schema、权限快照、token/time/step budget 和取消 context。 +- 所有 Agent 输出必须通过 JSON Schema 或显式 Go struct 校验。 +- 工具调用必须经过 Tool Registry 和 Policy Engine;模型输出不能直接执行 SQL、HTTP、文件或桌面动作。 +- 每一步都持久化状态和可审计摘要,但不保存模型隐藏推理链。 +- 同一业务事件通过 dedupe key 保证不会重复执行。 +- 自动发送属于高风险副作用,生成回复和执行发送必须是两个独立步骤。 + +### 10.2 Go 接口示意 + +```go +type Agent interface { + Descriptor() Descriptor + Run(ctx context.Context, req RunRequest) (RunResult, error) +} + +type Descriptor struct { + Kind string + Capabilities []string + InputVersion string + OutputVersion string +} + +type RunRequest struct { + RunID string + ParentStepID string + Input json.RawMessage + Permissions PermissionSet + Budget Budget + Trace TraceContext +} + +type Tool interface { + Name() string + Validate(input json.RawMessage, permissions PermissionSet) error + Execute(ctx context.Context, input json.RawMessage) (json.RawMessage, error) +} +``` + +模型调用应为单独接口: + +```go +type ModelGateway interface { + Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) + Stream(ctx context.Context, req GenerateRequest, sink DeltaSink) (GenerateResponse, error) +} +``` + +### 10.3 运行状态 + +`queued -> running -> waiting_confirmation | completed | failed | cancelled` + +步骤状态: + +`pending -> running -> completed | skipped | failed | cancelled` + +Supervisor 在每一步检查: + +- run 是否取消; +- deadline; +- 剩余 token、模型调用数和工具调用数; +- agent/tool 是否在权限快照内; +- 输入是否仍绑定最新 conversation observation; +- 是否需要人工确认。 + +## 11. Agent 设计 + +### 11.1 Conversation Supervisor Agent + +职责:处理一条新聊天观察或测试消息,选择必要的子 Agent,生成最终候选回复。 + +典型 DAG: + +```text +ChatRecordReader + -> Intent/LLM Worker + -> Knowledge Entity Agent + -> Employee Policy Agent + -> Response LLM Worker + -> Safety/Boundary Validator + -> WeChat Action Orchestration Agent +``` + +约束: + +- 无需 VLM 时不调用 VLM; +- 无需知识时不强制检索; +- 子 Agent 失败按类型降级,不能悄悄编造结果; +- 关键知识必须带 `knowledgeId/version/chunkId` 引用; +- 没有足够事实时输出澄清问题或转人工,不强行回答。 + +### 11.2 LLM Model Agent + +定位:受控文本模型 worker,不持有业务状态。 + +能力: + +- 意图分类; +- 结构化信息抽取; +- 回复生成; +- 文本摘要; +- 蒸馏阶段的策略归纳和反例生成。 + +实现: + +- 根据 `modelProfileId` 路由 provider; +- 请求包含固定 system policy、Agent prompt、最小上下文和 JSON schema; +- 支持 streaming 和 non-streaming; +- provider 429/5xx 使用抖动退避,超时和取消立即终止; +- 记录 model、latency、token usage、finish reason 和错误类别; +- 对结构化输出只允许有限次 repair,仍失败则返回明确错误。 + +禁止: + +- 直接执行桌面动作; +- 任意读取数据库和文件; +- 自己扩展工具权限; +- 将模型生成内容当作已验证事实。 + +### 11.3 VLM Model Agent + +定位:处理明确授权的图片输入和测试附件,输出结构化视觉结果。 + +适用: + +- 用户在对话或 Agent 测试中明确提交的图片; +- 产品未来允许的显式视频帧分析; +- 非标注调试的视觉能力实验。 + +不适用: + +- 知识库图片/视频自动描述; +- 标注截图、窗口截图、区域坐标解析; +- 未经授权的视频全量理解。 + +知识库 `purpose=knowledge_source` 且 `kind=image|video` 时,VLM Tool 必须拒绝调用;检索只使用标题、标签和人工 `description`。 + +输出示例应是结构化事实候选,并带 `confidence` 和 `uncertainFields`,由上层业务 Agent 决定是否可用,不能直接形成动作。 + +### 11.4 WeChat Action Orchestration Agent + +职责:把已批准的业务决策转换为短期语义动作计划,不直接控制鼠标键盘。 + +输入: + +- conversation fingerprint; +- latest observation ID; +- candidate reply; +- 托管策略、quiet hours、联系人 allow/block 规则; +- `confirmBeforeSend`; +- 当前错误计数。 + +输出仅允许: + +- `focus_input`; +- `replace_text`; +- `request_send`; +- `wait_and_reobserve`; +- `abort`。 + +安全规则: + +- 默认不使用坐标动作; +- 发送前必须再次比对会话 fingerprint; +- 回复为空、内容超长、联系人 block、quiet hours、窗口变化或 observation 过期时中止; +- `confirmBeforeSend=true` 时只填充输入框并等待确认; +- 连续错误达到阈值后暂停自动发送并发出 `engine.state.changed`; +- 每个 plan 只能执行一次,重复回执幂等。 + +### 11.5 Chat Record Reader Agent + +职责:管理聊天读取流程、消息归一化、跨页去重和增量游标;不直接读取标注文件。 + +分层: + +1. Rust Native Collector 从当前微信窗口获得本地观察; +2. Rust 输出规范化 `conversation.observed`; +3. Go Chat Reader 校验 schema、conversation fingerprint 和顺序; +4. 根据 message fingerprint、相邻消息上下文和时间窗口去重; +5. 写入 `conversation_messages`; +6. 发布 `conversation.updated` 供 Supervisor 使用。 + +消息 ID 不能只由 `sender+content` 决定,否则重复发送相同文本会被误去重。建议组合:会话 fingerprint、方向、规范化内容、页面邻接 fingerprint、可见时间和首次观察序号。 + +如果评审要求 Go 内 VLM 直接读取微信截图,就必须先修改第 14 节的本地隐私边界;按当前契约不可实施。 + +### 11.6 Employee Distillation Agent + +职责:把已上传且解析完成的文本材料转成可版本化的员工能力包。 + +持久化状态机与 API stage 对齐: + +1. `source_validation`:文件状态、数量、最小有效对话数; +2. `data_cleaning`:编码、空记录、重复、角色归一化; +3. `scene_clustering`:按业务场景聚类; +4. `strategy_extraction`:提取目标、触发条件、话术策略; +5. `boundary_induction`:提取禁止行为、优惠/承诺边界和转人工条件; +6. `counterexample_generation`:生成越界和歧义样本; +7. `evaluation`:固定评测集 + 新反例评测; +8. `completed`:生成不可变 EmployeeVersion 草稿。 + +蒸馏产物不能只有一段 prompt,应包含: + +```json +{ + "persona": {}, + "scenePolicies": [], + "decisionRules": [], + "responsePatterns": [], + "prohibitedBehaviors": [], + "escalationRules": [], + "positiveExamples": [], + "counterExamples": [], + "sourceRefs": [], + "evaluation": {} +} +``` + +发布前要求: + +- 所有 sourceRef 可追溯; +- overallScore 达到阈值; +- overreachRate 满足阈值; +- 评估使用的 job revision 等于当前 revision; +- 发布事务同时生成 Employee、EmployeeVersion 和 bindings。 + +### 11.7 Knowledge Summary Agent + +职责:只对文本知识生成辅助摘要、标题建议、主题和章节概览,降低检索上下文长度。 + +边界: + +- 输入只来自解析后的 TXT 或操作者填写的媒体 description; +- 不对知识图片或视频调用 VLM; +- 摘要是派生数据,不能替换原文; +- 每条摘要保存 source chunk refs、模型版本和生成时间; +- 知识版本变化后旧摘要失效并异步重建; +- 摘要失败不能阻止原始文档发布,但 UI 应显示派生状态。 + +### 11.8 Knowledge Entity Agent(知识体 Agent) + +本文将“知识体 Agent”定义为版本化知识组织与检索 Agent,不是第二个自由对话 Agent。 + +职责: + +- 将 KnowledgeVersion 组装成稳定的 Knowledge Entity; +- 管理文本 chunk、人工媒体描述、标签、摘要和来源引用; +- 根据 Agent/Employee 的知识授权过滤候选; +- 使用 FTS5 BM25 召回并执行确定性重排; +- 返回带证据的 KnowledgeContext; +- 检查知识启停状态和绑定版本,禁止命中已停用知识; +- 记录 retrieval trace,用于详情页 hit metrics。 + +建议返回: + +```json +{ + "query": "退款期限", + "items": [ + { + "knowledgeId": "kb_refund", + "version": "v1.8", + "chunkId": "chunk_01...", + "sourceFileId": "file_01...", + "score": 0.87, + "text": "……", + "sourceKind": "document_text" + } + ] +} +``` + +`sourceKind` 可为 `document_text | manual_media_description`,不能出现 `generated_media_description`。 + +### 11.9 Employee Runtime Agent + +员工不是简单的 Agent ID 集合。运行时应加载不可变 EmployeeVersion: + +- 根据场景选择已授权能力 Agent; +- 注入画像、边界和 escalation rules; +- 限制知识库为 employee bindings 与 agent bindings 的交集; +- 汇总子 Agent 结果,生成候选回复; +- 记录采用、人工修改、拒绝和异常,形成 calls/metrics; +- 不在线自我修改 prompt,重新学习必须通过 redistill 和新版本发布。 + +## 12. 关键业务流程 + +### 12.1 Agent 测试流式回复 + +1. HTTP 创建 test session,固定 agent release 或 draft revision 快照; +2. 前端上传附件并等待解析 ready; +3. WS 发送 message command,服务端以 `clientMessageId` 幂等受理; +4. 事务写 user message、assistant placeholder 和 run; +5. 发送 accepted; +6. Supervisor 执行知识检索、模型调用和校验; +7. delta 按 sequence 推送; +8. 完整消息、usage 和 test result 原子落库; +9. 发送 completed/failed; +10. draft 修改后旧测试立即失效。 + +取消只取消当前 generation context,不删除已有 user message。 + +### 12.2 知识发布 + +1. 创建/更新 draft; +2. 文件上传完成; +3. 文档解析为 TXT,媒体等待人工 description; +4. preview 汇总阻塞错误; +5. publish 事务固定 file IDs、内容哈希和 baseVersion; +6. 生成 KnowledgeVersion; +7. 文本切块并更新 FTS5; +8. 写 knowledge.changed outbox; +9. 异步运行 Knowledge Summary Agent; +10. 已绑定 Agent 若使用“跟随当前版本”,下次调用读取新版本;若绑定固定版本则不变。 + +### 12.3 微信自动回复 + +1. Rust 确认当前窗口和本地标注有效; +2. Native Collector 产生新的语义 observation; +3. Chat Reader 去重并持久化; +4. Conversation Supervisor 读取最近消息、长期摘要和联系人策略; +5. Knowledge Entity Agent 检索授权知识; +6. Employee Runtime Agent 应用场景策略; +7. LLM 生成候选回复; +8. Policy Validator 检查敏感内容、越权、quiet hours 和发送权限; +9. WeChat Action Agent 生成短期计划; +10. Rust 重新检查会话,确认或自动执行; +11. Rust 返回逐动作结果; +12. Go 写 agent run、employee call 和结构化日志。 + +任一步不确定都应中止发送,而不是“尽力点击”。 + +### 12.4 员工蒸馏 + +1. 创建 draft、绑定解析完成文件; +2. start 在事务内创建 job 和 stage rows; +3. worker 分批清洗和聚类,并在 checkpoint 后更新 progress; +4. 每阶段写结构化中间产物,不把全部上下文塞入单次 prompt; +5. 评估失败可重跑 evaluation,但不重复前面已完成阶段; +6. cancel 设置 `cancelRequested`,worker 在批次边界安全停止; +7. publish 固定 job revision 和评估结果,创建 EmployeeVersion。 + +## 13. 模型网关 + +### 13.1 Provider 抽象 + +统一能力: + +- `Generate`; +- `Stream`; +- `SupportsVision`; +- `SupportsJSONSchema`; +- `CountOrEstimateTokens`; +- `HealthCheck`。 + +每个 profile 配置: + +- provider、endpoint、modelName; +- context window 和 max output; +- request timeout; +- max concurrency; +- 每分钟请求/token 限制; +- 支持的输入类型和结构化输出方式。 + +### 13.2 重试 + +可重试:连接重置、429、部分 5xx;不可重试:401、无效模型、输入超长、内容策略拒绝、schema 长期不合法。 + +生成回复不能无条件重试,因为第一次请求可能已在上游完成。使用 provider request ID 和本地 run step 幂等语义,最多有限次重试,并在日志中标明 attempt。 + +### 13.3 Prompt 管理 + +- 固定安全 prompt 编译进版本化资源; +- 用户 custom prompt 是独立字段; +- Agent release 保存 prompt hash 和 runtime version; +- prompt 组合顺序固定:系统安全规则 -> Agent 角色 -> Employee policy -> KnowledgeContext -> ConversationContext -> 当前输入; +- 对外日志只保存 prompt hash、字符数和模板版本,不保存完整系统 prompt。 + +## 14. 安全与权限 + +### 14.1 权限模型 + +工具权限使用明确 code: + +- `knowledge.query`; +- `conversation.read_current`; +- `conversation.read_history`; +- `attachment.read`; +- `model.llm.invoke`; +- `model.vlm.invoke`; +- `message.compose`; +- `message.request_send`; +- `employee.policy.read`。 + +Agent release 固定权限快照。Supervisor 只能取交集,不能给子 Agent 增权。 + +### 14.2 文件安全 + +- 用户文件名只作展示,不参与存储路径; +- 拒绝路径穿越、NTFS alternate data stream 和非法设备名; +- Office ZIP 限制展开文件数、总大小和压缩比; +- 预览响应设置安全 Content-Type、Content-Disposition 和 `X-Content-Type-Options: nosniff`; +- 不执行 Office 宏、嵌入对象或上传文件内脚本; +- 视频只支持 Range 读取,不自动启动转码或识别。 + +### 14.3 日志脱敏 + +记录:requestId、resourceId、agentKind、stage、latency、token、errorCode。 + +禁止:API Key、完整 prompt、完整聊天原文、附件正文、截图路径、绝对本机路径、窗口 title/handle。 + +模型上游错误体先通过 allowlist 提取 code/message,不能原样写日志。 + +## 15. 可观测性与故障恢复 + +- 每个 HTTP 请求、WS command、job 和 agent run 共享 trace/request ID; +- `/bootstrap` 返回 serverVersion 和 schema version; +- 提供只监听 loopback 的 `/health/live` 和 `/health/ready`; +- ready 检查 SQLite、迁移状态、文件目录和 job runner,不强制检查外部模型; +- 每次模型调用记录 usage 和耗时; +- outbox backlog、job queue depth、WS connection count 和连续错误数写结构化指标; +- Go sidecar 崩溃后,running job 由 lease 恢复;running agent generation 标记 interrupted,由客户端重试,不自动重复发送消息; +- Rust 发现 Go 不可用时暂停托管和所有发送动作。 + +## 16. 测试策略 + +### 16.1 单元测试 + +重点覆盖: + +- revision 冲突; +- idempotency key 同体/异体; +- cursor 稳定分页; +- 文件类型三重校验; +- Range 请求; +- chat message 去重; +- Agent 权限交集和 budget; +- 微信动作计划过期、会话变更和重复执行; +- 知识媒体禁止 VLM、只索引人工描述; +- distill 状态机的取消和恢复。 + +### 16.2 Repository 集成测试 + +每个测试使用独立临时 SQLite: + +- migrations 从空库可完成; +- foreign key 和 cascade/restrict 符合预期; +- 聚合更新与 outbox 在同一事务; +- FTS5 查询、停用过滤和版本绑定; +- job claim/lease/recovery; +- 并发 revision 更新只有一个成功。 + +### 16.3 API 契约测试 + +从 OpenAPI 生成或维护 request/response fixtures,覆盖: + +- 所有 `api-design.md` 页面入口; +- 错误 code; +- 未知字段和 body size; +- ticket 单次消费; +- WS 事件顺序和断线续传; +- 文件上传、分片、完成、取消和清理。 + +### 16.4 Agent 评测 + +Agent 不能只测“调用成功”。为每个 Agent 建固定 golden scenarios: + +- 意图和结构化抽取准确性; +- 知识引用是否真实存在; +- 无知识时是否拒绝编造; +- 员工边界和转人工规则; +- 自动发送是否在危险条件下阻断; +- VLM 不确定输出是否正确标注; +- 蒸馏重跑的一致性和 overreach rate。 + +模型评测记录 provider/model/prompt/runtime version,避免模型升级后结果不可解释。 + +### 16.5 Windows 端到端测试 + +在真实 Tauri + Windows + 微信测试账号环境验证: + +- sidecar 启停和 token 传递; +- 标注数据未进入 Go 日志、DB 和 Bridge; +- observation/action fingerprint 防误会话; +- confirmBeforeSend; +- quiet hours、block list 和异常暂停; +- 进程崩溃后不重复发送; +- DPI、窗口移动和窗口切换时动作中止。 + +## 17. 分阶段开发路线 + +### 阶段 0:契约冻结 + +交付: + +- 修正 annotationComplete/WS 事件边界; +- 增加 model profiles、agentKind/capabilities/toolPolicy; +- 明确 VLM 可接受输入; +- 补 Agent Run 诊断接口; +- 生成并评审 OpenAPI 3.1; +- 固定 Native Bridge 消息 schema。 + +验收:前端、Rust 和 Go 对每个字段的所有权无冲突。 + +### 阶段 1:服务基础层 + +交付: + +- 新目录结构和依赖装配; +- loopback HTTP、session token、request ID、错误包络; +- SQLite migrations、revision、idempotency; +- settings、SecretStore、bootstrap; +- WS ticket、hub、outbox、续传; +- health、结构化日志和优雅关闭。 + +验收:基础契约测试通过,重启后状态和事件可恢复。 + +### 阶段 2:文件中心与知识库 + +交付: + +- 小文件、分片上传、Range 下载; +- TXT/PDF/DOCX/PPTX 解析; +- 媒体人工 description; +- knowledge draft/version/publish/status/delete; +- FTS5、引用关系和清理任务; +- Knowledge Summary/Entity Agent。 + +验收:文档、图片、视频知识按契约工作;任何路径都不会自动生成媒体描述。 + +### 阶段 3:模型网关与 Agent Runtime + +交付: + +- LLM/VLM model profiles; +- provider adapter、streaming、限流、取消和 usage; +- Agent Registry、Supervisor、Tool Registry、Policy Engine; +- run/step/tool trace; +- budget、schema validation 和权限交集。 + +验收:用 fake model 可完全重放流程;真实 provider 的错误分类和取消正确。 + +### 阶段 4:Agent 管理与测试会话 + +交付: + +- agent draft、release、knowledge binding、status/delete; +- test session/messages; +- WS accepted/delta/completed/failed; +- 发布前 revision-matched test gate; +- Conversation、LLM、VLM Agent。 + +验收:关闭并重新打开测试窗口可恢复消息;重复 command 不重复生成。 + +### 阶段 5:蒸馏与员工 + +交付: + +- distill draft/job/stages/checkpoint/cancel; +- Employee Distillation Agent; +- evaluation、report export、publish; +- EmployeeVersion、bindings、calls、redistill、clone; +- Employee Runtime Agent。 + +验收:长任务重启可恢复,取消有效,发布产物可追溯到来源和评估版本。 + +### 阶段 6:微信生产运行链路 + +交付: + +- Native Bridge; +- Chat Record Reader Agent; +- WeChat Action Orchestration Agent; +- engine state、startup/stop/pause; +- semantic observation、action receipt 和异常暂停; +- Tauri/Rust 人工确认和最终动作执行。 + +验收:窗口切换、会话变化、过期计划、重复回执和 sidecar 崩溃均不会误发消息。 + +### 阶段 7:替换 demo 与发布加固 + +交付: + +- 前端移除 mock/localStorage 业务状态; +- 删除已迁移的旧 `package main` demo 路径,不保留双实现; +- Windows sidecar 打包、迁移备份和回滚; +- 性能、资源和安全测试; +- 完整端到端发布检查。 + +验收:`api-design.md` 页面映射中的所有按钮和状态都来自真实接口或明确的 Tauri IPC。 + +## 18. 主要风险与决策 + +| 风险 | 影响 | 建议决策 | +|---|---|---| +| API 的 annotation 状态所有权冲突 | Go/Tauri 双事实源 | 阶段 0 先修契约,不带冲突开工 | +| 旧 `.ppt` 解析 | Go 生态弱、攻击面大 | 依赖受限 LibreOffice,或首期移除 `.ppt` | +| 微信截图不能进入 Go,但旧 demo 依赖截图 VLM | chat_sync 无法直接迁移 | Native Collector 输出语义消息;若要 Go 看截图,必须显式改隐私契约 | +| 多 Agent 自由协作 | 循环、成本失控、难审计 | Supervisor + DAG + budget,不做自由自治群聊 | +| 自动发送误会话 | 高风险用户事故 | fingerprint + observation binding + expiry + Rust 二次校验 | +| SQLite 并发写 | busy/锁竞争 | 单写队列、短事务、WAL、worker 并发受控 | +| 大视频上传 | 磁盘占满、恢复困难 | quota、分片 TTL、预留空间检查、无自动处理 | +| 模型输出不稳定 | 流程失败或越权 | schema、有限 repair、policy validator、确定性工具层 | +| 蒸馏任务成本和上下文过大 | 超时、结果不可重复 | 分阶段 checkpoint、小批次、产物版本化 | +| API Key 泄露 | 严重安全问题 | Windows Credential Manager、日志 allowlist、禁止 argv/env/DB 明文 | + +## 19. 评审时建议确认的决策 + +1. 是否接受 Tauri/Rust 是唯一桌面动作执行者,Go 只输出语义动作计划? +2. 是否接受 Go 不读取任何标注/窗口截图,Chat Reader 只接收 Native Collector 的结构化消息? +3. VLM 首期是否只用于用户授权的图片附件,而不用于知识库媒体和标注截图? +4. `.ppt` 是依赖 LibreOffice,还是从首期格式中移除? +5. 是否接受模块化单体和 SQLite durable jobs,而不是首期引入微服务和外部队列? +6. 自动发送首期是否强制 `confirmBeforeSend=true`,稳定后再开放联系人级别白名单? +7. “知识体 Agent”是否按本文定义为版本化知识组织/检索 Agent,而不是可以自行修改知识的自治 Agent? +8. 模型 profile 是否需要首期支持多个 provider,还是先实现一个 provider、但保留多 profile 数据模型? +9. 员工蒸馏最低发布阈值、overreach 上限和人工复核规则由谁配置? +10. Agent run trace 在 UI 中展示到什么粒度,哪些字段只供本地诊断? + +## 20. 最小可行落地范围 + +如果希望先验证架构而不是一次完成全部功能,最小闭环应包含: + +1. HTTP/SQLite/WS 基础层; +2. settings + 模型密钥; +3. 文件上传和 TXT/PDF/DOCX/PPTX 文本解析; +4. 知识库发布 + FTS5 + Knowledge Entity Agent; +5. 单 LLM profile + Agent Runtime; +6. Agent draft/test/publish; +7. Native Bridge 的一条 observation -> candidate reply -> confirm -> send 闭环; +8. 全链路 run/step 日志和误发送防护。 + +VLM、员工蒸馏、复杂视频、自动发送可在该闭环稳定后加入。这个切分没有改变最终架构,也不会产生需要保留的临时双实现。 diff --git a/docs/page-completion-audit.md b/docs/page-completion-audit.md new file mode 100644 index 0000000..8b3b299 --- /dev/null +++ b/docs/page-completion-audit.md @@ -0,0 +1,817 @@ +# `src/` 页面完成度审计与业务补全方案 + +> 审计日期:2026-07-20 +> 审计范围:`src/App.jsx`、`src/pages/`、`src/windows/`、页面级公共组件、`src/data/mockData.js`、`src/utils/navigation.js` +> 口径:一个可独立进入的主 Tab 或 hash 路由计为一个页面级视图;同一路由的步骤、页签和弹层不重复计数。 +> 限制:本报告以当前前端代码、Tauri/Rust/Go 接线和浏览器可运行行为作为现状证据,并在此基础上评审最小 MVP 功能闭环。Windows 微信窗口捕获、自动回复和 Go sidecar 未做生产环境端到端验收,因此只判断接线,不把规划项表述为已实现能力。 + +--- + +## 1. 执行摘要 + +当前 `src/` 已经不是“占位页集合”。主界面、设置、日志工作台、知识库、Skill、员工和蒸馏相关的 13 个页面级视图都有完整布局,原先的 6 个二级占位页也已替换为可交互表单/详情页。 + +但“页面做完”“单项能力闭环”“最小 MVP 闭环”是三套不同口径。当前只有标注保存形成了局部技术闭环,引擎启停接入部分 Tauri 命令;知识、Skill、员工、蒸馏、设置和日志仍主要来自 `mockData` 或组件内存状态。更关键的是,方案尚未完成“界面观测 → 消息归并 → 知识检索与建议 → 人工确认/规则拦截 → 发送执行 → 界面执行证据 → 结果核对与处理水位”的核心业务链。因此当前是可走查原型,不是可实际使用的 MVP。 + +### 1.1 页面统计 + +共识别 **13 个页面级视图**:4 个主 Tab、1 个标注路由、8 个二级窗口路由。 + +| 完成层级 | 数量 | 占比 | 页面 | +|---|---:|---:|---| +| L3:可恢复、可审计的生产闭环 | 0 | 0% | 尚无页面通过真实 Windows 全链路与异常验收 | +| L2:部分真实能力接入 | 2 | 15.4% | 屏幕区域标注、微信分身/引擎控制台 | +| L1:交互原型完成,业务未持久化 | 11 | 84.6% | 其余列表、设置、工作台、创建/详情/蒸馏页 | +| L0:纯占位页面 | 0 | 0% | 当前路由已不再使用 `PlaceholderWindow` | +| **合计** | **13** | **100%** | — | + +按布局覆盖口径:**13/13 已有页面结构**,但其中仍有死控件、错误路由和模拟成功反馈,不能表述为“交互完成”。 +按可持久化、可恢复、可审计、可端到端完成用户目标的严格口径:**0/13 达到生产 L3,2/13 部分接入,11/13 仍为原型**。 + +### 1.2 最高优先级问题 + +1. **核心链路未接通**:现有页面尚未把“微信界面观测 → 消息识别 → 知识检索 → 建议回复 → 人工确认 → 发送 → 结果记录”串成真实闭环。 +2. **大量状态仍是 mock 或页面内存**:知识、设置、日志和引擎状态在重开窗口后不能稳定恢复,也无法跨窗口保持一致。 +3. **发送结果语义不准确**:RPA 只能证明执行了输入/点击或观察到本方气泡,不能声称服务端送达或对方已读。 +4. **标注契约漂移**:启动检查、React 标注页和原生标注 overlay 使用的必需区域不一致。 +5. **引擎状态不可恢复**:当前 React 页面以内存状态驱动,停止操作又会直接终止 Agent/视觉流;异常退出后的真实状态没有统一来源。 +6. **消息识别不能依赖文本哈希冒充渠道 id**:相同文本可能重复出现,OCR 也可能抖动,必须保留观测证据并允许人工处理歧义。 +7. **发送前缺少必要校验**:焦点、当前会话、输入框原草稿、待发全文和标注坐标任一变化都可能造成错发。 +8. **未知结果不能自动重试**:点击后崩溃或证据不足时,自动重试可能重复发送,必须先由用户核对。 +9. **知识检索需要段落级引用**:当前“整篇返回”不适合长文;MVP 只需复用页面已有的 SQLite FTS5/BM25 方向,不扩展为向量平台。 +10. **非核心页面会分散实现面**:Skill、Employee、蒸馏和复杂知识向导已有原型,但不应阻塞首条人工确认回复闭环。 +11. **现有成功反馈不能作为完成证据**:保存、测试、启停、导出等动作若未调用真实持久层或原生命令,必须明确显示演示状态。 +12. **Windows 自动化仍未验收**:旧 Go/Python 视觉路径明显偏 macOS,必须以真实 Tauri Windows 端到端记录作为最终证据。 +13. **MVP 导航脱离现有信息架构**:方案新增“待处理 / 运行”主页面并把设置提升为主 Tab,但当前只有“微信分身 / 知识库 / Skill / 员工蒸馏”四个主 Tab,设置和日志均是二级窗口。MVP 应扩展现有微信分身页承载当前会话与人工确认,不再创建新的一级业务页面。 +14. **现有提示词与托管设置被错误裁掉**:当前设置已有自定义提示词、开启托管、发送前人工确认、异常自动暂停、异常阈值和消息等待时间;这些直接约束现有自动回复功能,MVP 必须持久化,而不是只保留模型、回复规则和知识。 +15. **Outbox 缺少原子执行权**:`queued → executing` 若只是先查后改,双窗口、重复回调或残留进程可能同时发送。MVP 需要 SQLite 条件更新一次性取得执行权,但不需要引入分布式租约或安全账本。 +16. **聚合状态与执行证据未闭合**:ReplyRun、Approval、Outbox 和 SendAttempt 各有状态,但没有明确由谁同步终态;SendAttempt 只有证据 hash 又无法在“结果未知”页面展示证据。 +17. **现有名单规则在单会话 MVP 中语义不完整**:黑名单可直接阻断当前会话;白名单原本服务主动联系/自动发送,本轮既不主动联系也不全自动发送,若继续展示会成为无效果配置,应从 MVP 设置隐藏。 +18. **缺少“已处理到哪里”的会话水位**:基线只能阻止首次启动处理历史消息,不能证明哪些新消息已经发送、拒绝或转人工;重启后可能再次为同一批消息生成建议。 +19. **新消息使旧建议失效后没有替代任务**:方案只规定 supersede 旧 ReplyRun,却没有规定如何把旧输入和新输入合并为新 Run,可能出现旧建议失效后再也不回复。 +20. **结果未知期间的新消息没有归宿**:未知发送结果必须阻断后续发送,但新观测仍会到达;若不保留为未处理输入,对账结束后会漏消息。 +21. **知识版本与引用模型不一致**:现有知识页已有版本和标签,方案却让 Passage 只引用可变 Knowledge;编辑正文后,历史建议的引用会指向新内容。 +22. **方案新增了当前产品不存在的“测试建议/dry-run”动作**:现有页面只有模型连接测试和组合提示词预览。MVP 首次建议应由布防后的真实测试消息产生,不另建测试业务流程。 +23. **黑名单按手输显示名无法可靠匹配**:同名联系人可能被误拦截。单会话 MVP 应只允许把当前已布防会话加入黑名单,并保存当时的会话头指纹。 + +## 2. 页面与导航结构 + +```mermaid +flowchart TD + App[App.jsx hash 分发] --> Main[MainShell] + App --> Annotate["/annotate(开发调试)"] + App --> Secondary[/window/*] + + Main --> Clone[微信分身] + Clone --> NativeOverlay[Windows 原生标注 overlay] + Main --> Knowledge[知识库] + Main --> Skills[Skill 市场] + Main --> Distill[员工蒸馏] + + Secondary --> Settings[settings] + Secondary --> Workbench[engine-logs] + Secondary --> KnowledgeCreate[knowledge-create] + Secondary --> KnowledgeDetail[knowledge-detail?id] + Secondary --> SkillCreate[skill-create] + Secondary --> SkillDetail[skill-detail?id] + Secondary --> EmployeeDetail[employee-detail?id] + Secondary --> DistillStart[distill-start?step] +``` + +### 2.1 路由与窗口机制 + +- 没有 React Router。`App.jsx` 读取 `window.location.hash`,将路由分成主窗口、`/annotate` 和 `/window/*`。 +- 当前仍可直接进入 React `/annotate`,但生产方案应把它降为开发调试路由;用户唯一标注入口是 ClonePage 调用 `enter_window_select_mode` 打开的 Windows 原生 overlay。 +- 主 Tab 只存在 `App` 内存中,刷新后回到“微信分身”;设置二级分区状态提升到 `App`,但字段值不在 `App` 中。 +- 浏览器模式下,`openWindow(route)` 通过修改 hash 导航。 +- Tauri 模式下,`openWindow(route)` 调用 `open_popup_window` 创建独立窗口;失败时降级为当前窗口 hash 跳转。 +- 二级窗口由 `SecondaryWindow` 根据 route 映射到 8 个实现。未知 `/window/*` 会降级显示 `PlaceholderWindow`,应改为明确的 404/未知窗口状态,避免把路由错误伪装成正常占位页。 +- `PlaceholderWindow.jsx` 当前仅用于未知二级路由兜底,不属于 13 个已命名页面。 + +### 2.2 公共壳与全局交互 + +- `TitleBar`:主窗口可打开设置、退出应用;二级窗口可关闭;支持 Tauri 窗口拖动。 +- `ThemeSwitch`:主题写入 `localStorage.theme`,通过 `storage` 事件在窗口间同步。 +- `BottomTabs`:切换 4 个主业务页面。 +- `WindowUI`:为二级窗口提供标题、页签、向导步骤、表单行、指标和底部动作栏。 +- `DraggableFab`:支持纵向拖动,设置移动阈值避免拖动后误触点击。 +- `Terminal`:自动滚动到最新日志,支持可选的查看全部与清理回调。 + + +### 2.3 MVP 目标导航 + +MVP 不新建一级“待处理”或“运行”页面,沿用当前窗口结构并缩减生产导航: + +1. **微信分身**:保留现有引擎状态、启动检查、暂停、停止、最近日志和标注入口;在同一页增加当前会话、待人工确认建议和未知结果核对区域。 +2. **知识库**:复用现有列表与详情入口,只支持纯文本/UTF-8 TXT、编辑、发布/停用、删除和段落检索预览。 +3. **设置二级窗口**:保留模型、提示词、回复规则、知识和托管配置中的 MVP 字段。 +4. **日志二级窗口**:复用 `/window/engine-logs` 展示真实运行日志。 + +Skill 市场与创建/详情、员工蒸馏与详情不进入 MVP 生产导航;保留源码时标记为开发预览。原生 overlay 是唯一生产标注入口。这样只扩展已有页面,不新增一级业务路由。 + +## 3. 逐页完成度 + +| # | 页面/路由 | 已有交互 | 数据/副作用 | 严格状态 | 未完成项 | +|---:|---|---|---|---|---| +| 1 | 微信分身 `/` | 引擎启停、启动前检查、标注入口、状态球、日志清理/查看 | Tauri command/event + 本地 state;浏览器使用演示分支 | **L2 部分接入** | 初始状态查询、真实模型健康、实时日志、重启、错误恢复和跨窗口同步 | +| 2 | 知识库主列表 | 搜索、类型/状态筛选、详情入口、新增 FAB | 页面内 `items` 常量 | **L1 原型** | 查询/分页/加载/错误态、真实详情同步、解析任务状态 | +| 3 | Skill 市场 | 搜索、分类筛选、卡片详情、启停、创建 FAB | 页面内 `initialSkills` + 本地 state | **L1 原型** | CRUD、测试、发布、授权、持久化启停、失败回滚 | +| 4 | 员工蒸馏 | 教程弹层、搜索/筛选、任务隐藏、详情/继续任务入口 | mock 员工 + 固定 72% 任务 | **L1 原型** | 后台任务、恢复、取消/重试、评估、发布和实时进度 | +| 5 | 屏幕区域标注(生产:原生 overlay;调试:`/annotate`) | 原生选窗/画框/保存;React Canvas 框选、编辑、删除和保存 | Tauri 截图/区域命令;浏览器存储仅用于调试捕获结果 | **L2 部分接入** | 单一生产契约、完整保存校验、未保存离开确认、Windows DPI/多屏端到端验收 | +| 6 | 设置 `/window/settings` | 7 个分区、输入/选择/开关、连接测试、保存/恢复、脏状态提示 | 组件本地 state + 延时模拟 | **L1 原型** | `get/save_config`、安全密钥存储、真实连接测试、校验、错误反馈、跨窗口同步 | +| 7 | 引擎日志 `/window/engine-logs` | 级别/模块/关键字筛选、暂停、自动滚动选项、清空、日志详情抽屉 | `mockData.logs × 3` + 派生字段 | **L1 原型** | 真实日志订阅;导出、复制按钮接线;容量、脱敏和结构化导出策略 | +| 8 | 新增知识 `/window/knowledge-create` | 4 步向导、基本信息、来源/文件输入、全文预览、发布设置、草稿/发布反馈 | 本地 state + 固定解析结果 | **L1 原型** | 文件读取、OCR/ASR、SQLite 落库、任务恢复、校验、失败重试 | +| 9 | 知识详情 `/window/knowledge-detail?id=…` | 5 页签、正文搜索/编辑、重新解析、创建版本、启停、删除 | 固定知识文案 + 本地 state | **L1 原型** | 读取路由 id、查询实体、引用保护、版本持久化、真实解析和删除 | +| 10 | 创建 Skill `/window/skill-create` | 5 步向导、提示词/schema/工具/知识库选择、测试、草稿/发布 | 本地 state + 固定测试结果 | **L1 原型** | schema 校验、真实沙箱、权限模型、版本化发布、持久化 | +| 11 | Skill 详情 `/window/skill-detail?id=…` | 5 页签、概览指标、启停、执行/权限/测试/版本列表 | 固定“客户意图识别”数据 + 本地 state | **L1 原型** | 读取 id、内置/自定义权限边界、真实编辑/测试/审计、列表同步 | +| 12 | 员工详情 `/window/employee-detail?id=…` | 5 页签、画像/指标/授权/版本/调用展示、启停 | 固定“销售冠军 Aileen”数据 + 本地 state | **L1 原型** | 读取 id、真实评估/调用/版本数据、持久化启停/回滚、列表同步 | +| 13 | 开始蒸馏 `/window/distill-start?step=…` | 5 步向导、来源、隐私、能力提取、评估、发布、草稿 | 本地 state + 固定进度/评估结果 | **L1 原型** | 读取 step/job id、后台任务、脱敏执行、评估计算、发布落库和恢复 | + +**MVP 范围说明**:上述 13 项是现状审计口径,不是 MVP 必做清单。MVP 只交付扩展后的“微信分身”、精简知识页、精简设置二级窗口、真实日志二级窗口和原生标注;Skill/员工/蒸馏及对应复杂创建/详情窗口全部移出发布阻断范围并从生产导航隐藏。 + +### 3.1 局部闭环页面 + +屏幕区域标注已具备“读取输入 → 用户编辑 → 保存结果 → 通知/退出”的局部技术闭环,同时支持 Tauri 命令与浏览器演示路径;但必需类型契约尚未统一,且 Windows 原生窗口捕获、DPI 和多屏未完成验收,因此只能评为 L2,当前没有页面达到生产 L3。 + +### 3.2 已有视觉结构、未完成业务的页面 + +以下 11 个页面已不属于占位页:知识/Skill/蒸馏列表、设置、引擎工作台、知识创建/详情、Skill 创建/详情、员工详情、蒸馏向导。它们可以用于布局和流程走查;死控件、固定结果和未持久化状态必须明确标记为“演示”,不能作为交互可用、数据已保存或任务已执行的证据。 + +### 3.3 部分接入页面 + +微信分身已经调用 `start_vision_stream`、`start_agent`、`stop_agent`、`stop_vision_stream`,并监听 `engine-state-changed` 与 `annotation-completion-changed`。启动前会依次检查模型配置、标注和微信窗口;这是有效接线。但页面初始状态、日志和部分健康信息仍不是单一真实来源。 + +### 3.4 全局布局与内容规范 + +#### 主窗口公共布局 + +- 使用纵向三段式:顶部 `TitleBar`、中部可滚动内容区、底部四栏 `BottomTabs`。 +- 顶栏标题随当前 Tab 变化为“微信分身 / 知识库 / skill市场 / 员工蒸馏”;右侧依次为主题、设置、重启、退出。 +- 底部导航固定四等分;数据项格式为 `{ id, label, icon, badge }`。当前 `badge` 数据存在“3”“新”,但 `BottomTabs` 未实际渲染徽标。 +- 主内容统一使用 20px 左右内边距;列表页采用垂直卡片流,主要卡片圆角 14px。 + +#### 二级窗口公共布局 + +- 使用顶部紧凑标题栏 + 单一内容画布;标题栏右侧为主题切换和“关闭”。 +- 创建类页面使用“顶部步骤条 + 居中最大宽度内容 + 底部 sticky 操作栏”。 +- 详情类页面使用“实体标题/版本/状态 + 横向页签 + 内容卡片”。 +- 通用展示格式:状态使用 `StatusPill`,统计使用 `Metric`,键值信息使用 `InfoRow`,表单标签使用 `FormRow`。 + +#### 通用文案和数值格式 + +| 类型 | 当前格式 | 示例 | +|---|---|---| +| 版本 | `v.` 字符串 | `v2.4`、`v3.2` | +| 短日期 | `MM-DD` | `06-08` | +| 完整日期 | `YYYY-MM-DD` | `2026-06-05` | +| 日期时间 | `YYYY-MM-DD HH:mm` | `2026-06-08 18:20` | +| 日志时间 | `HH:mm:ss`;详情补日期和毫秒 | `12:30:04`、`2026-07-20 12:30:04.238` | +| 百分比 | 数字加 `%` | `72%`、`98.4%` | +| 评分 | 整数或 `整数 / 100` | `96`、`92 / 100` | +| 数量 | 数字加中文单位 | `32 次`、`3 个`、`1,286 条会话` | +| 文件大小 | 整数加 `KB` | `428 KB` | +| 字数 | 千位分隔或“万字” | `8,600 字`、`2.6 万字` | +| 状态 | 中文显示文本 | `已生效`、`解析中`、`已启用`、`已关闭` | +| 实体路由 | query string | `?id=kb-refund`、`?step=3` | + +### 3.5 每个页面的布局、文案与数据格式 + +#### 1. 微信分身 + +- **布局**:上半部居中状态球 `NodeSphere`、状态标题/说明和主操作;下半部是自动滚动终端日志。运行后主操作变为两列“暂停/恢复自动发送”和“停止引擎”。 +- **核心文案**:状态标题为“微信引擎未运行 / 微信引擎运行中 / 微信引擎运行异常”;说明分别描述等待运行、已处理消息和启动失败。未标注时显示“标注微信”,已标注时显示“启动引擎”,异常时显示“重新启动”。 +- **启动浮层**:标题“正在检测引擎启动环境”,逐项展示“大模型连接情况 / 微信区域标注情况 / 微信窗口连接情况”和“检测中... / 成功 / 失败”,最终文案为“全部检查成功,正在启动引擎...”或“检查失败,启动流程已终止。” +- **数据格式**: + - `engineStatus: "idle" | "running" | "error"`; + - 日志 `{ level: "info" | "warning" | "error", message: string }`; + - 检查项 `{ id, label, detail, status, passed }`,其中 `status` 为 `waiting/checking/passed/failed`; + - 原生状态事件使用 `payload.enabled: boolean`。 +- **展示格式**:日志 `message` 当前有 `[HH:mm:ss] 文本` 和无时间前缀文本两种格式,建议统一拆分 `timestamp` 字段。 + +#### 2. 知识库主列表 + +- **布局**:类型分段器 → 搜索框 → 状态筛选与刷新按钮 → 汇总行 → 知识卡片列表 → 右下角新增 FAB。 +- **核心文案**:类型为“全部 / 视频 / 图片 / 文件 / 案例 / 其他”;搜索占位“搜索标题、标签或完整正文”;状态为“全部状态 / 已生效 / 解析中 / 解析失败 / 待审核 / 已停用”;汇总为“共 6 项 · 生效 2 · 解析中 1 · 异常 1”。 +- **卡片内容**:标题、类型标签、业务标签、状态、可选解析进度条、版本、更新时间、引用数、正文长度或错误原因;错误卡增加“查看原因 / 重试”。 +- **空态文案**:“暂无匹配知识”“调整类型、状态或搜索关键词后重试。”“清除筛选”。 +- **数据格式**:`{ id, title, type, tags: string[], updated: "MM-DD", refs: number, version, status, detail, progress?: number, error?: boolean }`。`progress` 范围按 `0..100` 百分比展示。 + +#### 3. Skill 市场 + +- **布局**:类型分段器 → 名称/能力搜索 → 状态筛选与刷新 → Skill 卡片流 → 新增 FAB。 +- **核心文案**:类型“全部 / 内置 / 自定义”;搜索占位“搜索 Skill 名称或能力”;状态“全部状态 / 已启用 / 已关闭”。 +- **卡片内容**:名称、`类型 · 版本`、启用状态、三个能力标签、测试/绑定知识/最近调用摘要;右下提供“查看/编辑”和启停开关。 +- **空态文案**:“暂无匹配 Skill”“调整筛选条件后重试。” +- **数据格式**:`{ id, name, type: "内置" | "自定义", enabled: boolean, version, tags: string[], meta: string, tested: boolean }`。当前 `meta` 把多个字段拼成展示字符串,正式数据应拆为 `knowledgeCount/callCount/testStatus/updatedAt`。 + +#### 4. 员工蒸馏主列表 + +- **布局**:顶部产品说明与双 CTA → 当前任务进度卡 → 员工搜索/状态筛选 → 已发布员工卡片列表;教程使用居中模态框。 +- **核心文案**:主标题“把优秀话术沉淀成员工能力”,说明“导入聊天记录、话术和案例,形成可评估、可授权的员工。”;CTA 为“查看教程 / 开始新的蒸馏”。 +- **任务卡**:“销售冠军 Aileen 话术蒸馏”“能力提取 · 剩余约 3 分钟”“72%”“成功 248 · 失败 7”,操作为“查看 / 取消”。 +- **教程文案**:依次说明授权导入、隐私脱敏、能力提取/失败样本、效果评估与授权发布。 +- **员工卡片**:名称、领域、版本、启停状态、画像特征、评分、本周调用和“查看详情”。 +- **数据格式**: + - 员工 `{ name, version, enabled, traits, domain }`; + - 列表派生 `score` 和 `weeklyCalls`; + - 当前任务应规范为 `{ id, name, stage, progress, successCount, failureCount, etaSeconds }`,当前均为硬编码展示值。 + +#### 5. 屏幕区域标注 + +- **布局**:全屏左右两栏。左侧固定 320px 配置栏,包含标题/状态、截图元信息、区域编辑表单、区域列表和取消/保存;右侧为自适应黑底 Canvas。 +- **核心文案**:模块名“屏幕区域标注”,页面标题“微信 RPA 坐标配置”,操作提示“拖拽截图区域创建矩形框”。浏览器不可用时显示“浏览器预览模式无法截屏,请在 Tauri 应用中使用”。 +- **表单**:区域名称;区域类型“联系人区域 / 聊天内容区域 / 输入框区域 / 发送按钮区域 / 自定义区域”;区域用途描述;“删除当前框 / 取消 / 保存”。 +- **元信息**:截图像素、`ScaleFactor`、区域数量、来源(窗口/桌面);选中区域展示 `image: [x1, y1, x2, y2]` 和 `screen: [x1, y1, x2, y2]`。 +- **数据格式**: + - 捕获 `{ screenshotPath, screenshotWidth, screenshotHeight, scaleFactor, source?, totalMs?, screenListMs?, captureMs?, saveMs? }`; + - 区域 `{ id: UUID, name, type, description, bbox_image: number[4], bbox_source: number[4], bbox_screen: number[4], scaleFactor }`; + - 保存文件 `{ app: "wechat", screenshotPath, screenshotWidth, screenshotHeight, scaleFactor, source, regions, createdAt, updatedAt }`; + - 坐标顺序统一为 `[x1, y1, x2, y2]`,最多保留两位小数;时间使用 ISO 8601。 + +#### 6. 设置 + +- **布局**:左侧 180px 分区导航,右侧滚动配置内容,底部固定“保存状态 + 恢复/保存配置”。小屏改为顶部横向分区。 +- **分区**:基础配置、提示词配置、回复规则、Skill 管理、员工管理、知识库管理、托管配置。 +- **基础配置**:标题“大模型配置”;字段为模型厂家、请求地址、API Key 状态、模型名称、请求超时(毫秒);按钮“更换 / 删除 / 测试连接”;结果“尚未测试当前配置”或“● 连接成功 · 324ms · doubao-pro-32k”。 +- **提示词配置**:固定提示词只读;自定义提示词显示 `{length} / 4000`;操作“预览组合提示词”。 +- **回复规则**:免打扰模式、自动加好友、主动联系客户、开启蒸馏;白名单/黑名单区支持名称搜索、添加、删除,示例数据“张三 / 客户测试群”。 +- **Skill/员工管理**:总开关 + 每项开关;摘要格式分别为`类型 · 版本 · 测试状态`和`版本 · 评分 · 领域`。 +- **知识库管理**:固定算法“SQLite FTS5 / BM25”,最大返回数量,搜索字段“标题 / 标签 / 完整正文”,以及逐知识启停。 +- **托管配置**:开启托管、本地消息服务、发送前人工确认、异常自动暂停、连续异常阈值、单日消息最长等待秒数。 +- **数据格式**:当前没有统一配置对象,各分区由局部 state 和非受控 `defaultValue/defaultChecked` 混合组成。正式格式应采用报告 5.3 的 `AppConfig`,数值字段使用 number、开关使用 boolean、名单使用 `{ id, displayName }[]`。 +- **保存状态文案**:“所有修改已保存 / ● 有未保存修改 / 配置已保存”。 + +#### 7. 分身引擎日志 + +- **布局**:顶部过滤工具栏 → 自动滚动和动作栏 → 深色等宽日志表 → 底部计数/接收状态;点击日志后从右侧打开 390px 详情抽屉。 +- **筛选文案**:“全部级别 / info / warning / error”“全部模块”;搜索占位“搜索消息或 requestId”。 +- **操作文案**:“暂停接收 / 继续接收”“导出”“清空视图”;空态“当前筛选下暂无日志”。 +- **表格列**:时间、级别、模块、消息;详情补充 requestId、上下文和完整日期时间,并提供“复制完整日志 / 仅筛选此 requestId”。 +- **状态文案**:“已显示 N / M 条 · 缓存上限 10,000”“最后更新 12:30:15”或“已暂停接收 · 新日志 3 条”。 +- **数据格式**:`{ id: number, time: "HH:mm:ss", level: "info" | "warning" | "error", module: "agent" | "vision" | "capture" | "retriever" | "model" | "policy" | "sender", message: string, requestId: string | "—" }`。当前原始 `message` 内又包含时间,存在重复时间字段。 + +#### 8. 新增知识库 + +- **布局**:四步向导“基本信息 / 来源导入 / 全文预览 / 发布设置”,内容最大宽度 5xl,底部操作为“上一步 / 保存草稿 / 下一步或发布知识”。 +- **第 1 步**:知识标题、知识类型、标签、适用范围、说明;类型为“文件 / 图片 / 视频 / 案例 / 其他”。 +- **第 2 步**:上传文件、粘贴文本、选择本地目录;拖拽区支持 `PDF / DOCX / TXT / PNG / JPG / MP4`;文件行展示名称、大小、解析状态和“移除”;附敏感信息脱敏选项。 +- **第 3 步**:解析汇总、进度条、来源文件切换、完整文本预览;提示“SQLite 将对标题、标签和完整正文建立 FTS5 索引,并通过 BM25 排序。” +- **第 4 步**:发布摘要、发布方式、版本说明、敏感信息确认;发布方式为“立即生效 / 保存为待审核 / 仅保存草稿”。 +- **数据格式**:页面状态 `{ title, type, tags: string[], files, content, publishMode }`;文件 `{ name, size: " KB", status }`;正式提交应把 `size` 改为字节 number,显示层再格式化。 + +#### 9. 知识库详情 + +- **布局**:实体标题/版本/状态和“更多” → 五页签“概览 / 全文内容 / 引用关系 5 / 版本历史 7 / 错误记录” → 页签内容。 +- **页头文案**:“售后常见问题与退款边界”“v1.7”“已生效/已停用”“文件 · 售后 · 更新于 2026-06-05 · 引用 82 次”。 +- **概览**:来源、解析方式、创建人、当前版本、生效时间、最近检索;内容摘要;本周命中、引用 Skill、绑定员工、正文字数;操作“重新解析 / 创建新版本 / 停用或重新启用 / 删除知识”。 +- **全文内容**:搜索占位“在完整正文中搜索”,结果文案“找到 N 段包含‘关键词’的内容”,正文 textarea。 +- **其他页签**:引用项使用“名称 · 类型”;版本使用“版本 · 说明 · 日期”;错误使用“日期:错误说明”。 +- **数据格式**:当前数据散落为显示字符串。正式详情应包含 `knowledge`、`references[]`、`versions[]`、`errors[]`;计数页签由数组长度生成,不写死在标题中。 + +#### 10. 新增 Skill + +- **布局**:五步向导“基本信息 / 执行定义 / 权限授权 / 沙箱测试 / 发布设置”,底部为草稿和前后步骤/发布操作。 +- **基本信息**:Skill 名称、类型、描述、能力标签。 +- **执行定义**:系统提示词、输入 Schema、输出 Schema、可调用工具;工具包括查询商品信息、检索知识库、发送微信消息、创建工单。 +- **权限授权**:可访问知识库;危险权限“允许直接发送消息 / 允许主动联系客户”,并显示人工确认、托管和白名单限制。 +- **沙箱测试**:测试输入、运行测试;结果展示是否通过、耗时、tokens、结构化结果和工具轨迹。 +- **发布设置**:版本、授权知识数、工具数、测试状态、发布说明、“发布后立即启用”;未测试时提示“发布前必须至少完成一次成功测试。” +- **数据格式**:当前输入/输出 Schema 是“字段名 → 类型字符串”的 JSON 示例;正式格式应采用合法 JSON Schema。测试结果建议 `{ status, durationMs, tokenUsage, output, toolTrace[] }`。 + +#### 11. Skill 详情 + +- **布局**:实体标题/版本/状态和启停按钮 → 五页签“概览 / 执行定义 / 权限授权 / 测试记录 / 版本历史”。 +- **页头文案**:“客户意图识别”“v2.1”“已启用/已关闭”“内置 Skill · 更新于 2026-06-16 · 最近调用 16 次”。 +- **概览**:能力说明、三个标签,以及最近调用、成功率、平均耗时、绑定知识四项指标。 +- **其他页签格式**: + - 执行定义:`输入/输出/提示词:内容`; + - 权限:`资源或动作 · 已授权/未授权`; + - 测试:`日期 · 场景 · 结果 · 耗时`; + - 版本:`版本 · 说明 · 当前状态`。 +- **数据格式**:当前各页签是字符串数组;正式数据应拆成 `definition`、`permissions[]`、`testRuns[]`、`versions[]`,状态和耗时保持机器可计算类型。 + +#### 12. 员工详情 + +- **布局**:员工标题/版本/状态和启停按钮 → 五页签“概览 / 能力与授权 / 评估报告 / 版本历史 / 调用记录”。 +- **页头文案**:“销售冠军 Aileen”“v3.2”“已启用/已关闭”“企微私域成交 · 更新于 2026-06-18”。 +- **概览**:员工画像、适用/禁止边界、综合评分、本周调用、采纳率、异常;绑定能力和知识;操作“编辑画像 / 管理授权 / 管理知识 / 重新蒸馏 / 复制为新员工 / 更多操作”。 +- **其他页签格式**: + - 授权:`名称 · 已授权/未授权/已绑定`; + - 评估:`指标 数值`; + - 版本:`版本 · 说明 · 当前`; + - 调用:`HH:mm · 场景 · 结果`。 +- **数据格式**:当前概览与页签均为固定字符串。正式数据需要 `{ employee, profile, grants, evaluation, versions, calls }`,其中评分和百分比使用 number,显示时再添加 `%` 或 `/ 100`。 + +#### 13. 开始蒸馏 + +- **布局**:五步向导“来源导入 / 隐私处理 / 能力提取 / 效果评估 / 发布员工”,内容最大宽度 5xl,底部为保存草稿和步骤/发布操作。 +- **来源导入**:入口“导入微信聊天 / 上传文档 / 选择已有案例”;来源卡显示名称、条数和“格式有效”;另有数据日期范围和导入范围设置。 +- **隐私处理**:扫描汇总“423 个联系人、86 个手机号、31 个订单号”;规则包括匿名编号、手机号后四位、订单号隐藏、地址保留省市;展示脱敏前后对照。 +- **能力提取**:标题“正在提取能力”“任务可在后台继续运行”,进度 72%;阶段为数据清洗、场景聚类、策略提取、边界归纳、反例生成。 +- **效果评估**:综合评分 92/100,状态“达到发布标准”;指标为场景命中、拒答准确、越权率、一致性;样例行展示场景、结果和通过状态。 +- **发布员工**:员工名称、业务领域、授权能力、授权知识、初始启停状态;按钮“发布员工”。 +- **数据格式**: + - 来源建议 `{ id, name, kind, itemCount, status }`; + - 隐私扫描 `{ contactCount, phoneCount, orderCount, rules[] }`; + - 任务阶段 `{ name, current, total?, valueText, done }`; + - 评估 `{ score, sceneHitRate, refusalAccuracy, overreachRate, consistency, cases[] }`; + - 当前代码仅保存 `{ step, notice, name }`,其余为静态 JSX 数据。 + +### 3.6 数据格式共性问题 + +1. **展示文本与领域数据混合**:`meta`、版本历史、测试记录、调用记录大量使用“字段 · 字段”字符串,无法可靠排序、筛选和国际化。 +2. **同一概念格式不统一**:知识更新时间同时存在 `06-08`、`2026-06-08`、`2026-06-08 18:20`;日志时间既在 `time` 字段中,也嵌入 `message`。 +3. **计量值被字符串化**:文件大小、字数、耗时、token、评分、百分比均应在数据层保存 number,并由显示层加单位。 +4. **中文状态被当作枚举**:应使用稳定英文/代码枚举,中文只作为映射文案。 +5. **详情子数据是字符串数组**:引用、版本、错误、权限、测试、调用都应改为带 `id` 和独立字段的对象数组。 +6. **路由参数与页面状态脱节**:实体 `id`、任务 `jobId`、步骤 `step` 应有统一解析和校验函数。 +7. **Schema 不是标准 JSON Schema**:当前仅是字段到类型的示例对象,正式执行契约至少需要 `type`、`properties`、`required`。 + + +--- + +## 4. 当前交互逻辑清单 + +### 4.1 有真实 Tauri 接线的交互 + +| 交互 | 当前调用链 | 结论 | +|---|---|---| +| 启动引擎 | 模型配置检查 → `load_regions` → `find_wechat_window` → `start_vision_stream` → `start_agent` | 接线有效;Agent 启动失败会回滚视觉流 | +| 停止引擎 | `stop_agent` → `stop_vision_stream` | 接线有效;需增加部分失败后的真实状态重查 | +| 打开标注 | `enter_window_select_mode`,并监听标注完成事件 | 接线有效;实际生产入口更接近原生 overlay 流程 | +| React 标注保存 | `read_screenshot_bytes` / `load_regions` / `save_regions` | Tauri 模式具备原生读写;浏览器存储只承担截图结果传递,不能保存标注文件 | +| 弹出/关闭窗口 | `open_popup_window` / `close_current_window` | 通用窗口链路已接入 | +| 退出应用 | `exit_application` | 主窗口退出已接入 | + +### 4.2 仅当前页面内有效的交互 + +- 搜索、筛选、Tab/步骤切换、教程弹层和日志详情抽屉。 +- Skill 列表启停、知识详情启停、Skill 详情启停、员工详情启停。 +- 设置字段修改、模拟连接测试、脏状态和保存成功提示;“恢复”只清除脏状态,不恢复字段值。 +- 创建知识、创建 Skill、蒸馏向导的步骤推进、草稿/发布提示。 +- 引擎日志暂停显示状态、关键字筛选、requestId 筛选和清空视图。 + +共同特点:有效动作只更新当前 React state 或显示 notice;刷新、关闭二级窗口或从另一窗口查看时不会保留。页面中仍有大量带按钮外观但没有 handler 的控件,见 4.4。 + +### 4.3 纯模拟逻辑 + +- 设置连接测试:固定延时后显示固定成功结果。 +- 知识创建页:文件解析数量、100% 进度、正文长度和影响范围均为固定演示。 +- Skill 创建页:测试按钮立即返回固定结果、1.8 秒、820 tokens 和固定工具轨迹。 +- 蒸馏页:72% 进度、脱敏统计、能力阶段和评估指标均为静态内容。 +- 引擎日志:将 `mockData.logs` 重复三次并派生时间、模块和 requestId。 +- 知识引用、版本记录、员工评分、最近调用和日志上下文均为静态内容。 +- 浏览器模式引擎启动会模拟成功,应始终标注为“演示模式”。 + +### 4.4 无效或存在契约错误的交互 + +| 位置 | 问题 | 用户风险 | +|---|---|---| +| 主标题栏“重启” | 无 `onClick` | 看似可用,点击无反馈 | +| 知识详情 | URL 的 `id` 未读取 | 所有卡片展示同一知识 | +| Skill 详情 | URL 的 `id` 未读取 | 所有卡片展示同一 Skill | +| 员工详情 | URL 的 `id` 未读取 | 所有卡片展示同一员工 | +| 蒸馏当前任务 | `?step=3` 未读取 | 点击继续后仍回到第 1 步 | +| 设置保存/恢复 | 保存只切换 `dirty/saved`;恢复不还原字段 | 关闭窗口后配置丢失,恢复按钮也可能造成误解 | +| 日志导出/复制 | 按钮没有 handler | 用户无法获得文件或复制结果 | +| 未知二级路由 | 自动显示 `PlaceholderWindow` | 路由错误被伪装成正常占位页面 | + +--- + +## 5. 数据、状态与契约审计 + +### 5.1 当前真实依赖 + +- Tauri commands:引擎启停、视觉流、微信窗口查找、标注模式、截图读取、区域读写、窗口创建/关闭、应用退出。 +- Tauri events:`engine-state-changed`、`annotation-completion-changed`。 +- 浏览器存储:主题、React 标注页截图捕获结果、等待状态和错误传递。 + +### 5.2 当前 mock 或易失数据 + +- `skillItems`、`employeeItems`、`logs`、设置分区和设置内知识条目来自 `src/data/mockData.js`;知识主列表和 Skill 主列表另有各自的页面内常量。 +- 创建向导、详情页、设置和启停动作存在各自页面 state 或静态 JSX 数据中。 +- 多窗口没有共享 store,也没有服务端/原生数据重新查询机制。 + +### 5.3 MVP 最小数据模型 + +只为现有功能闭环保存必要数据,避免为本轮非目标预建领域模型: + +```text +AppConfig + revision, provider, endpoint, model, requestTimeoutMs, keyConfigured, + customPrompt, quietHours, blockList: { displayName, headerFingerprint }[], + knowledgeEnabled, knowledgeMaxResults, knowledgeSearchFields, + activeKnowledgeIds, hostingEnabled, localMessageServiceEnabled, + confirmBeforeSend: true, abnormalPauseEnabled, + consecutiveErrorLimit, messageWaitSeconds, updatedAt + +Knowledge + id, title, tags, status, currentVersionId, updatedAt + +KnowledgeVersion + id, knowledgeId, version, contentHash, text, createdAt + +KnowledgePassage + id, knowledgeVersionId, ordinal, text, sourceLocator + +AnnotationConfig + windowFingerprint, regions, geometryHash, revision, updatedAt + +EngineState + lifecycle, agent, visionStream, wechatWindow, + activeConversationId?, paused, consecutiveErrors, + lastError, updatedAt + +Conversation + id, headerFingerprint, baselineObservationSeq, + lastHandledObservationSeq, armedAt, active, manualTakeover, + contextVersion, updatedAt + +MessageObservation + id, conversationId, seq, frameHash, normalizedContent, + contentHash, direction, confidence, observedAt, + state: pending | included | handled | manual + +ReplyRun + id, conversationId, inputObservationIds, contextVersion, + configRevision, proposedContent, + citations: { knowledgeVersionId, passageId, sourceLocator, excerpt }[], + replyDeadlineAt, state, outcome?, createdAt, completedAt?, error? + +Approval + id, runId, revision, approvedContent, contentHash, + state, decidedAt + +OutboxMessage + id, runId, conversationId, normalizedContent, contentHash, + state, executionId?, expiresAt, startedAt?, createdAt, updatedAt + +SendAttempt + id, outboxId, executionId, state, + beforeEvidence?, afterEvidence?, resolution?, + startedAt, finishedAt? + +LogEvent + id, time, level, module, message, requestId? +``` + +SQLite 是本机唯一业务状态源;API Key 由系统密钥库保存。页面只显示后端状态,不自行制造成功结果。`Approval.runId` 与 `OutboxMessage.runId` 分别唯一;同一会话只允许一个非终态 ReplyRun。 + +Outbox 执行权使用一次原子条件更新取得:`UPDATE outbox SET state='executing', execution_id=?, started_at=? WHERE id=? AND state='queued'`。只有受影响行数为 1 的当前 Tauri 执行器可以输入和点击;应用启动时先把遗留 `executing` 标为 `unknown_result`,完成核对前不消费新 Outbox。该约束只解决本机重复执行,不引入分布式租约、跨设备协调或额外产品模块。 + +### 5.4 前端服务边界 + +页面与 Tauri `invoke` 之间只增加薄服务层,覆盖现有页面需要的真实动作: + +```text +configService: get/test/save +knowledgeService: list/get/upsertText/publish/disable/searchPassages +annotationService: load/start/save/validate +engineService: getState/start/stop/restart/pause/subscribeState/subscribeLogs +conversationService: armCurrent/disarm/getCurrent/subscribeObservations +replyService: listPending/get/approve/reject/regenerate +outboxService: list/get/cancelPending/reconcileUnknown +``` + +每个动作返回 `{ data, revision }` 或结构化错误 `{ code, message, fieldErrors, retryable }`。`approve(runId, revision, contextVersion, configRevision, contentHash)` 是唯一能创建 Outbox 的页面命令;仅当页面所见版本全部仍匹配时提交,重复同内容批准返回原结果,不同内容或旧版本返回冲突。页面不得直接 enqueue 或重试发送。`getState/listPending/getCurrent` 负责微信分身页首次加载,事件只通知重新查询。 + +### 5.5 产品主闭环与必要技术不变量 + +MVP 只实现现有功能能够组成的一条链: + +```mermaid +flowchart LR + A[标注并启动] --> B[观察当前会话] + B --> C[识别新文本] + C --> D[检索知识段落] + D --> E[生成建议] + E --> F[人工批准或拒绝] + F -->|批准| G[持久 Outbox] + G --> H[发送前复检] + H --> I[输入并点击一次] + I --> J{UI 证据} + J -->|本方气泡| K[记录 observed_sent] + J -->|证据不足| L[人工核对] +``` + +只保留完成该链必须满足的不变量: + +1. MVP 只观察操作者已打开并明确布防的一条当前一对一文本会话;不扫描联系人列表、不点击未读项、不自动切换会话。 +2. 首次布防、重新标注或重启后先建立基线;屏幕上已有内容不自动进入回复链。 +3. OCR 文本不是稳定渠道 id;相同文本、OCR 抖动或归属不明时保留观测并转人工,不能静默合并或重复建任务。 +4. 建议必须冻结当前输入、知识引用和会话版本;出现新消息、切换会话、修改标注或停用知识后,旧建议和未执行 Outbox 失效。 +5. 每条回复逐条人工确认;Outbox 只能由批准动作创建,拒绝或编辑后未重新确认不能发送。 +6. 发送前验证当前微信窗口、会话头、输入框为空、待发全文和标注区域;用户已有草稿不得清空或覆盖。 +7. 发送只执行一次完整文本输入和一次发送按钮点击;不静默截断、不自动拆分、不以回车作为未验证降级。 +8. 点击后只能记录“观察到本方气泡”或“结果未知”,不能显示服务端送达或已读。 +9. `unknown_result` 不自动重试;用户核对为未发送后,也必须重新生成或重新确认后创建新 Outbox。 +10. 应用停止或异常退出后不得自动重放正在执行或结果未知的发送;先恢复状态并核对。 +11. 生产键鼠副作用集中在 Tauri 侧固定发送流程;模型输出、Go Action 和 Python 实验脚本不能直接驱动生产键鼠。 +12. 运行状态、知识、设置、Outbox 和日志来自真实后端;浏览器演示必须明确标注演示模式。 +13. 应用只允许一个主实例;Outbox 仍必须通过 SQLite 条件更新取得执行权。每个 SendAttempt 使用唯一 `executionId`,迟到结果仅在 Outbox 仍为 `executing` 且 id 匹配时写入,否则只记录错误。 +14. “结果未知”界面必须能读取 SendAttempt 保存的发送前后结构化观测或聊天区域裁剪;只有 hash 不足以支持人工判断。 +15. MessageObservation 使用会话内单调递增 `seq`;Conversation 以 `baselineObservationSeq/lastHandledObservationSeq` 记录基线和处理水位,不能用无顺序语义的 UUID 比较先后。首次布防只建立基线,之后仅将水位后的稳定入站观测纳入 ReplyRun。 +16. 新消息到达且当前 Run 尚未点击发送时,旧 Run/Approval/未执行 Outbox 同时 supersede;稳定窗口结束后,用“旧 Run 尚未处理的输入 + 新观测”创建唯一替代 Run,不能只失效不补建。 +17. 当前存在 `executing/unknown_result` 时不创建新 Run,但继续把新观测保存为 pending;结果核对完成后,再从处理水位之后生成下一条 Run。 +18. `observed_sent/reconciled_sent/rejected/manual_takeover/reconciled_unresolved/expired` 均收口本次输入并推进处理水位;`reconciled_not_sent` 不推进水位,页面只提供“重新生成”或复用现有“拒绝/转人工”完成收口。 +19. 黑名单只能从当前已布防会话执行“加入黑名单”,保存显示名和 `headerFingerprint`;加入后立即停用该会话、收口当前输入并停止继续观察。再次布防时只按完整指纹精确阻断;指纹变化视为未知会话并要求操作者重新确认,不能仅凭同名放行或拦截。移出黑名单不会自动重新布防;MVP 隐藏自由输入添加和白名单。 + +### 5.6 MVP 最小价值闭环 + +本轮只交付一个可重复的本机价值链,不扩展为团队协作或通用运营平台: + +| 阶段 | 用户目标 | MVP 必须提供 | 闭环信号 | +|---|---|---|---| +| 首次配置 | 完成运行前提 | 模型连接、托管设置、单微信窗口、标注、启动检查 | 启动检查全部通过 | +| 首次建议 | 确认识别和建议正确 | 布防后的真实测试消息、观测归并、知识引用 | 生成可审阅且尚未批准的建议 | +| 首次发送 | 安全完成一次真实动作 | 人工确认、发送前复检、Outbox、UI 执行证据 | 观察到本方气泡或明确进入未知对账 | +| 异常恢复 | 不因崩溃重复发送 | 持久状态、未知结果、人工对账、急停 | 重开后不自动重放副作用 | + +只有这四步在真实 Windows 测试账号上可重复完成,才称为 MVP 闭环;页面数量、资源 CRUD 和模拟成功不计入。 + + +### 5.7 MVP 定位与范围 + +MVP 收窄为:**一名本机操作者,在一台 Windows 电脑上使用单个已登录微信窗口,手工打开并明确布防一条一对一纯文本会话,为基线后的新入站咨询生成有引用的建议回复,并在逐条人工确认后发送。** + +- 首要任务:在一条当前会话内缩短文本咨询等待时间,同时确保操作者可以审核、拒绝、切换和接管。 +- 非目标:联系人列表扫描、未读识别、自动切换联系人、多会话并行、群聊、语音、图片、视频、文件、小程序卡片、引用/撤回消息、主动营销、批量触达、自动加好友、多微信账号、白名单全自动发送、团队账号和远程同步。 +- 运行形态:本机单用户、单数据目录、单微信窗口、单条活动会话、用户自备模型密钥(BYOK);不实现账号系统、Workspace、RBAC 或远程控制面。 +- 非支持消息或会话切换只生成“转人工/重新布防”提示,不进入自动决策;支持矩阵外环境直接阻断运行。 +- MVP 验收只看首次就绪、建议生成、人工确认、UI 执行证据、未知结果收口以及确认错发/重发是否为 0;不新增运营指标或数据看板。 + + +### 5.8 核心状态与事务边界 + +页面不能直接修改业务状态: + +```text +ReplyRun + deciding → awaiting_approval → queued → executing → completed + ↘ rejected | failed | expired | superseded + executing → unknown_result → completed + completed.outcome: + observed_sent | reconciled_sent | reconciled_not_sent | reconciled_unresolved + +Approval + pending → approved | rejected | expired | superseded + +OutboxMessage + queued → executing → observed_sent + queued → cancelled | expired | superseded + executing → unknown_result + unknown_result → reconciled_sent | reconciled_not_sent | reconciled_unresolved + +SendAttempt + started → input_verified → action_performed → evidence_observed + 点击前失败 → failed + 点击后证据不足 → unknown_result +``` + +- 批准、创建 Outbox 和把 ReplyRun 改为 `queued` 在同一 SQLite 事务内完成;重复批准只能返回原结果,不能产生第二条 Outbox。 +- 批准命令必须同时匹配 ReplyRun 的 `state='awaiting_approval'`、revision、contextVersion、configRevision 和待批内容 hash;双击相同请求返回原 Outbox,任一字段变化则冲突并重新加载,不能批准旧建议。 +- 消费者使用条件更新把 Outbox 与 ReplyRun 同时改为 `executing`,并创建同一 `executionId` 的 SendAttempt;受影响行数不是 1 时不得执行键鼠动作。 +- Outbox 或 SendAttempt 每次状态变化都在同一事务内更新 ReplyRun;进入 `unknown_result` 时 ReplyRun 同步进入待核对,终态时同步写入 outcome。微信分身页只查询 ReplyRun 聚合状态,不跨表猜测。 +- 新消息、会话头变化、手工发送、重新标注、提示词/回复规则/模型配置变化或引用知识停用时,旧 ReplyRun、待审批项和未执行 Outbox 同时 `superseded`。 +- `replyDeadlineAt/expiresAt` 由现有“单日消息最长等待”配置计算;超过时间后 ReplyRun、Approval 和未执行 Outbox 同时 `expired`。免打扰或暂停不会延长旧消息时效,恢复后需要按最新上下文重新生成。 +- 点击发送前失败时关闭本次尝试,不自动回到 `queued`;需要继续时基于最新上下文重新生成并再次批准。 +- 点击发送后证据不足时进入 `unknown_result`;只能由用户标记“确认已发 / 确认未发 / 无法判断并转人工”。 +- 应用启动时先把遗留 `executing` 转为 `unknown_result`;完成核对前不消费新 Outbox。迟到回调必须同时匹配 `state='executing'` 与 `executionId`,不能复活已收口记录。 +- 同一当前会话只允许一条非终态 ReplyRun;`MessageObservation(conversationId, seq)`、`Approval.runId`、`OutboxMessage.runId` 和 `SendAttempt.executionId` 分别唯一,观测 seq 在 SQLite 事务内按会话递增。 +- Run 进入 `observed_sent/reconciled_sent/rejected/manual_takeover/reconciled_unresolved/expired` 时,在同一事务内把 `inputObservationIds` 标为 handled/manual 并推进 Conversation 处理水位。`reconciled_not_sent` 保留输入为 pending,直到用户重新生成或明确拒绝/转人工。 +- 新消息使未执行 Run 失效时,原输入回到 pending;稳定窗口结束后与新消息合并生成替代 Run。存在 `executing/unknown_result` 时只积累 pending,不并行创建新 Run。 + +### 5.9 不可信输入与模型输出边界 + +客户消息、OCR 文本和知识正文均是不可信内容。该边界只保护现有建议回复功能,不增加工具系统: + +1. 固定系统提示、自定义提示词、回复规则、当前消息和知识引用分开组装;消息或知识中的“忽略规则”“调用工具”等文字只能作为内容。 +2. 模型输出只接受建议正文、引用和风险提示;出现 `actions`、`tool_calls`、坐标、按键或未知字段时整次结果失败并转人工。 +3. 模型不能直接调用鼠标、键盘、发送消息、访问网页或执行 Go/Python Action;批准后的固定 Tauri 发送流程是唯一副作用入口。 +4. 提示词、模型配置或启用知识变化后,旧待批准建议失效;组合提示词预览只展示最终文本,不调用模型、不创建 Approval 或 Outbox。 +5. 验收至少覆盖客户消息和知识正文中的提示词注入,确认它们不能绕过人工确认或产生键鼠动作。 + +## 6. MVP 业务闭环设计 + +## 6.1 微信分身与引擎工作台 + +**目标**:主页面与日志工作台读取同一个真实 `EngineState`。 + +1. 页面打开时查询真实引擎状态,事件只做增量更新;操作失败后重新查询。 +2. 启动检查必须真实验证模型配置、标注完整性和微信窗口;任一失败都不启动 Agent。 +3. 启动 Agent 失败时停止已启动的视觉流,并显示真实错误。 +4. “暂停/恢复自动发送”改为“暂停/恢复已批准发送”;暂停不停止观测,也不能绕过逐条人工确认。 +5. 停止或重启时先停止接收新发送;正在执行或结果未知的项先进入核对,不能直接 kill 后显示安全停止。 +6. 工作台订阅真实日志,保留级别/模块/关键字筛选、暂停滚动、清空视图和详情复制。 +7. 浏览器降级明确显示“演示模式”,不用真实运行的成功文案。 + +**验收**:重开页面状态一致;启动检查失败会阻止启动;Agent 启动失败会回滚视觉流;主页面与工作台状态同步;停止/重启不会自动补发未确认结果;日志来自真实事件。 + +## 6.2 MVP 知识 + +**目标**:把现有知识页收敛为可持久化、可检索、可引用的文本知识,并保持版本引用不漂移。 + +1. 只支持粘贴纯文本和 UTF-8 TXT;PDF、DOCX、图片、视频、OCR/ASR 和后台解析任务不进入本轮。 +2. Knowledge 保存标题、标签、状态和当前版本;每次正文编辑发布新的不可变 KnowledgeVersion,再按段落建立指向该版本的 FTS5/BM25 索引。 +3. 搜索字段沿用现有标题、标签和完整正文;列表保留搜索、新增、编辑、发布/停用和删除,不实现复杂解析向导。 +4. 建议引用冻结 `knowledgeVersionId/passageId/sourceLocator/excerpt`;知识更新后,历史引用仍指向原版本,待批准建议则失效并按新版本重新生成。 +5. 停用或删除后,新建议不能再引用该知识;待批准建议同时失效。已完成 ReplyRun 只读取自身保存的引用快照,不把可变 Knowledge 当作历史证据。 + +**验收**:粘贴文本、TXT 导入、标签、保存、检索、引用、版本、停用和删除均持久化;不同知识 id 返回不同内容;更新正文不会改变历史建议引用;长文只返回相关段落。 + +## 6.3 MVP 提示词与回复规则 + +**目标**:复用现有“提示词配置”和“回复规则”,只负责生成建议,不建设 Skill 或工具编排。 + +1. 固定提示词只读,自定义提示词保留 4,000 字限制并持久化;“预览组合提示词”展示最终文本但不调用模型、不发送。 +2. 生成输入只包含固定/自定义提示词、当前会话文本、命中知识和现有回复规则。 +3. 免打扰和黑名单在生成前与发送前使用同一份已保存配置;把当前会话加入黑名单时立即停用布防、取消未执行发送并将当前输入转人工,移出后必须由操作者重新打开并布防。 +4. 自动加好友、主动联系客户、开启蒸馏和白名单在本轮隐藏,因为 MVP 不主动联系、不自动加好友、不蒸馏,也不存在白名单免人工确认。 + +**验收**:提示词和规则保存后重开不丢;组合预览不调用模型或发送;黑名单与免打扰能阻断发送;提示词、知识或规则变化会使旧待批准建议失效。 + +## 6.4 Skill、Employee 与蒸馏 + +本轮 **不实现** Skill CRUD、工具沙箱、自定义 Employee、聊天蒸馏、评估发布和在线反馈训练。相关页面保留为开发预览或从生产构建隐藏,不能连接模拟成功,也不能成为 G0–G3 的依赖。 + +## 6.5 设置中心 + +**目标**:把现有设置中的 MVP 必需项接到真实持久层,不新增设置分区。 + +1. 打开时调用 `get_config`;所有字段使用受控状态并维护 `dirty/saving/error`。“恢复”必须恢复最近一次已保存值,不能只清除 dirty。 +2. 基础配置保留厂家、请求地址、API Key、模型名称和请求超时;API Key 保存到系统密钥库,前端只接收 `keyConfigured`。 +3. “测试连接”使用当前表单值,返回鉴权失败、超时、模型不存在或网络不可达等真实结果。 +4. 提示词配置保留固定提示词、自定义提示词和组合预览;回复规则只保留免打扰,以及“将当前已布防会话加入/移出黑名单”,不保留自由输入名单。 +5. 知识库管理保留总开关、最大返回数、搜索字段和逐知识启停。 +6. 托管配置保留开启托管、本地消息服务、发送前人工确认、异常自动暂停、连续异常阈值和单日消息最长等待;MVP 中“发送前人工确认”固定开启且不可关闭,本地消息服务关闭时就绪检查失败且不能启动托管处理。 +7. Skill、员工、自动加好友、主动联系、开启蒸馏和白名单隐藏或明确标记为非 MVP,不保存为生效配置。 +8. 保存调用 `save_config(config, revision)`;并发冲突提示重新加载,保存失败保留表单内容且不覆盖旧配置。保存成功后主窗口重新查询模型与引擎就绪状态。 + +**验收**:重开配置不丢;恢复按钮恢复字段;密钥不回显;连接成功与各类失败都有真实反馈;托管或本地消息服务关闭时不能启动自动处理;人工确认始终开启;异常达到阈值后暂停新建议和发送;非 MVP 设置不会影响运行链。 + +## 6.6 屏幕区域标注 + +**目标**:以现有 Windows 原生透明 Tauri overlay 作为唯一生产入口,消除双实现和契约漂移。 + +1. ClonePage 的“标注微信”只调用 `enter_window_select_mode` 进入原生 overlay;React `/annotate` 降为显式开发调试路由,不进入正式导航、不发送生产完成事件,也不维护第二套保存规则。 +2. 单会话 MVP 的生产必需区域统一为 `conversation_header`、`chat_content`、`input_box`、`send_button`;不依赖 `contact_list/unread_message` 扫描和点击。原生保存校验、ClonePage 启动检查、Go/Rust 消费端和文案共同引用同一份契约;`custom` 只能附加,不能替代必需类型。 +3. 保存前校验必需类型、区域非空、边界合法、同类型唯一性;返回字段级错误。发送执行器启动前必须再次确认 `conversation_header` 和 `send_button`,消息检测前必须确认当前会话头指纹。 +4. 有未保存修改时关闭应二次确认;截图变化、微信版本变化或窗口尺寸变化后,旧坐标必须标记失效并要求重验,不能静默尺寸映射后直接用于发送。 +5. Windows/Tauri 环境实测窗口选择、DPI 缩放、多显示器、截图读取、保存、重新加载和退出通知;原生 overlay 之外的旧 Go/Python macOS 捕获路径不得作为 Windows 验收证据。 + +**验收**:正式入口只有原生 overlay;启动检查、保存和消费端使用同一必需类型;高 DPI/多屏坐标可复现;未保存退出不会静默丢数据;缺少 `conversation_header`、`chat_content`、`input_box` 或 `send_button` 均不能布防。生产运行不会扫描联系人列表、点击未读项或自动切换会话。 + +## 6.7 会话、建议、确认与发送 + +**目标**:打通现有自动回复方向所需的最窄真实链路。 + +1. 操作者在微信中手工打开目标一对一会话,再点击“布防当前会话”;系统记录会话头指纹并建立基线。 +2. 捕获只覆盖 `conversation_header/chat_content/input_box/send_button`;会话头变化、群聊或非文本消息直接转人工。 +3. 新文本稳定出现后,从 Conversation 处理水位之后取全部 pending 入站观测,检索当前启用知识版本的相关段落,并生成一条建议及不可变引用快照。 +4. 微信分身页的“当前会话与待确认”区域提供“批准并排队发送、编辑后重新确认、拒绝、转人工”;不新增一级待处理页,也不能使用模拟延时制造成功。 +5. 新消息在点击发送前到达时,旧建议和未执行发送失效;稳定后把旧输入与新消息合并生成替代建议。当前存在执行中或未知结果时只积累 pending 观测,对账结束后再生成。 +6. 发送前确认窗口、会话、输入框为空、待发全文和发送按钮;输入框已有草稿时保持原样并转人工。 +7. Tauri 发送流程只写入一条完整文本并点击一次 `send_button`;Go/Python 实验路径不作为生产发送入口。 +8. 点击后观察输入框变化和本方消息气泡;证据充分显示“已观察到本方气泡”,证据不足显示“结果未知”,不显示服务端送达或已读。 +9. 结果未知时不自动重试;用户完成“确认已发 / 确认未发 / 无法判断”后关闭原尝试,需要再发时重新展示并确认。 +10. 停止、重启或异常退出不得自动重放 executing/unknown 项;恢复后先展示待核对状态。 +11. Outbox 必须由当前唯一应用实例通过 SQLite 原子条件更新取得执行权;发送结果只接受同一 `executionId`,重复消费和迟到回调不能产生第二次发送或覆盖较新状态。 +12. 结果未知区域展示发送前后结构化观测或聊天区域裁剪以及三个核对动作;证据缺失时只能选择“无法判断并转人工”。 +13. 免打扰期间只保留新观测,不生成或执行回复;超过“单日消息最长等待”的观测和未执行 Outbox 转为 `expired`,结束免打扰后不能自动补发。 +14. 识别、检索、模型、预检或发送连续失败达到现有配置阈值时,`EngineState` 进入 paused 并停止新建议与发送;用户查看真实错误并手工恢复后计数清零。 +15. 观察到本方气泡、确认已发、拒绝、转人工、无法判断或消息过期时推进 Conversation 处理水位,保证重启后不重复生成;确认未发时不推进,用户必须选择重新生成或明确拒绝/转人工。 +16. “转人工”会设置 `manualTakeover=true` 并推进当前输入水位;人工模式下只保留界面状态,不生成新 Run。用户解除接管时重新确认当前会话并建立新基线,人工期间积压不自动回复。 + +**验收**:在真实 Tauri Windows、受支持微信版本和隔离测试账号中,从布防后的新文本到知识版本引用、建议、人工批准、一次发送和 UI 证据可重复完成;历史内容和已处理输入不重复生成;新消息会形成包含全部未处理输入的替代建议;未知结果期间的新消息不会丢失或并行发送;相同文本不被误吞;非空草稿不被覆盖;切换会话不会使用旧建议;点击后崩溃不会自动补发。 + +## 6.8 本地数据与密钥安全 + +**目标**:保护现有模型配置、知识、聊天观测和运行日志,不新增合规工单或备份产品。 + +1. MVP 只有当前 Windows 会话下的一名本地操作者,不实现账号、Workspace 或多角色。 +2. API Key 保存到系统密钥库;前端只读取 `keyConfigured`,密钥不得进入 localStorage、日志或诊断内容。 +3. SQLite 只保存完成当前功能所需的配置、知识段落、消息观测、回复状态和日志;原始整窗截图默认不持久化。 +4. 捕获只限当前已布防会话区域;会话头变化后停止持久化和模型请求。 +5. 模型请求只发送生成当前建议所需的文本和命中知识,不发送其他窗口内容。 +6. 日志默认脱敏,禁止记录 API Key、完整系统提示、剪贴板内容和无关聊天原文。 + +**验收**:密钥不回显且不出现在日志;未布防会话内容不落盘、不进入模型;诊断信息不包含密钥和无关聊天原文。 + +## 6.9 首次运行就绪 + +**目标**:让用户通过现有页面顺序完成第一次真实运行,不新增独立引导向导。 + +微信分身页显示就绪清单和直达入口,顺序为:模型连接 → 选择并标注微信窗口 → 托管与人工确认配置 → 导入一条测试知识 → 操作者手工打开测试会话并布防 → 发送一条基线后的测试消息 → 查看建议与引用 → 人工批准 → 观察本方消息气泡或进入结果核对。 + +**验收**:全新本地数据目录可从现有微信分身、知识、设置和标注入口完成首条有引用、经批准且有 UI 执行证据的测试回复;中途失败不显示成功,不依赖新路由或手工编辑配置文件。 + +## 6.10 运行日志与诊断 + +**目标**:把现有日志工作台接到真实运行事件,而不是扩展分析或训练平台。 + +1. 日志字段沿用页面现有结构:时间、级别、模块、消息、requestId。 +2. 支持级别/模块/关键字筛选、暂停滚动、清空当前视图和复制详情。 +3. 日志使用限长缓冲;前端重开后从后端读取最近记录。 +4. 每次建议、批准、发送尝试和未知结果使用同一 requestId 串联。 + +**验收**:启动、识别、检索、模型、批准、发送和错误均有真实日志;筛选与暂停不影响后台运行;日志不含 API Key 和无关聊天原文。 + +## 6.11 本地持久化与升级 + +**目标**:让现有配置、知识和运行状态重开不丢,不新增用户可见的备份/恢复业务。 + +1. SQLite schema 采用版本化迁移;迁移在事务中执行,失败时保持旧库不变并阻止启动引擎。 +2. 配置、知识、标注、当前会话、非终态 ReplyRun、Outbox、SendAttempt 和最近日志均从后端读取,不以 React state 作为唯一来源。 +3. 应用升级或启动后重新检查微信窗口、标注和当前会话;遗留 `executing` 先转 `unknown_result`,核对完成前不消费新 Outbox。 +4. 数据库损坏或版本不兼容时显示明确错误和数据目录位置,不伪装为空数据或自动覆盖。 + +**验收**:重开应用后配置、知识、当前会话与微信分身页待确认状态一致;迁移失败不破坏原库;升级后旧执行中发送不会自动重放。 + +## 6.12 非目标、运行前提与发布边界 + +MVP 明确不做:群聊与非文本自动回复、多微信账号、白名单自动发送、后台无人值守、团队协作、主动营销、自动加好友、跨设备同步、在线自学习、Skill/Employee/蒸馏、向量数据库和多渠道客服。运行时要求 Windows 会话未锁定、目标微信窗口属于已标注实例且状态可识别;窗口关闭、账号切换、版本/DPI 不兼容、界面证据不足或系统休眠后,一律暂停并要求重新就绪检查。支持矩阵外环境显示“不受支持”,不能静默降级。 + + +## 6.13 可靠性边界 + +发布前只验证主链必需的故障:断网、模型超时、微信窗口移动/关闭、会话切换、输入框已有草稿、sidecar 异常退出、应用强制退出和磁盘不可写。任何故障都必须停止新发送、保留可诊断错误,并且不得产生未批准发送、重复发送或跨会话发送。 + +## 6.14 故障处置 + +1. 复用“停止引擎”作为全局急停,并保留取消尚未执行项、重新标注和查看真实日志入口,不新增独立风险中心。 +2. 发生错发、重复发送、跨会话串话或密钥泄漏时,立即停止发送能力,不自动恢复。 +3. 候选版本记录应用、sidecar、Windows、微信、DPI 和模型版本,便于复现。 + +**验收**:急停可阻断尚未执行的 Outbox;高风险故障后不自动恢复发送;日志足以关联到具体 ReplyRun 和 SendAttempt。 + +## 7. 实施顺序与发布闸门 + +实施顺序围绕一条现有功能链,不按页面数量推进。 + +### G0:冻结最小契约 + +1. 统一原生标注区域:`conversation_header/chat_content/input_box/send_button`。 +2. 冻结单 Windows 操作者、单微信窗口、单条手工布防的一对一纯文本会话、逐条人工确认和 UI 证据口径。 +3. 定义最小数据模型、状态迁移、原子 Outbox 执行权、`executionId` 回写条件和结构化错误;移除页面模拟成功。 + +**出闸条件**:前端、Rust 和 sidecar 使用同一标注及发送契约;不再声称服务端送达;Skill、Employee、蒸馏不阻塞主链。 + +### G1:接通真实状态 + +1. 建立 SQLite 持久层和薄服务层,接通现有设置、知识、引擎状态、日志和标注。 +2. 持久化提示词、免打扰、当前会话黑名单、知识启停、托管开关、人工确认、异常暂停阈值和消息最长等待;实现当前会话布防、基线、处理水位、消息观测、不可变知识版本、段落 FTS5 检索和建议生成。 +3. 扩展微信分身页展示当前会话、待确认建议和未知结果;实现新消息合并替代 Run、未知期间 pending 积累和已处理水位恢复;页面重开与多窗口读取同一后端状态。 + +**出闸条件**:重开后配置、知识版本、引擎、处理水位和微信分身页待确认状态一致;基线历史消息与已处理输入不创建建议;新消息不会只失效旧 Run 而漏建替代 Run;托管关闭、黑名单、免打扰、消息过期和连续异常阈值均能阻断相应处理;日志来自真实事件。 + +### G2:交付人工确认回复闭环 + +1. 接通建议展示、引用、批准/编辑后重新确认/拒绝、Outbox 和一次发送。 +2. 完成单实例、SQLite 原子执行权、`executionId` 条件回写、窗口/会话/输入框/全文复检、非空草稿保护和发送前后证据。 +3. 完成未知结果人工核对、聚合状态同步、停止/崩溃恢复和禁止自动重发。 + +**出闸条件**:真实 Tauri Windows 测试账号可重复完成首条回复;未批准、重复和跨会话发送均为 0;双开应用、重复消费、迟到回调和点击后崩溃均不会补发或覆盖较新状态。 + +### G3:发布验证 + +1. 验证微信分身、知识、设置、日志和标注五个 MVP 界面的正常和错误状态。 +2. 演练断网、模型超时、窗口变化、会话切换、非空草稿、sidecar 退出、应用退出和磁盘不可写。 +3. 隐藏或明确标记未接真实后端的预览页面。 + +**出闸条件**:主链验收记录完整;浏览器 mock 不计作证据;Windows 支持范围和已知限制写实。 + +## 8. 完成定义与验收矩阵 + +页面从原型升级为 MVP 必须满足: + +- 数据来自真实后端状态,不来自 mock 或固定延时。 +- 重开窗口后状态不丢,多窗口读取一致。 +- 具备 loading、empty、error、success 和 disabled 等必要状态。 +- 失败不显示成功,敏感字段不回显、不进入日志。 +- 关键路径在真实 Tauri Windows 环境验证。 + +最小验收矩阵: + +| 领域 | 正常路径 | 必测异常路径 | +|---|---|---| +| 设置 | 加载、模型测试、保存 | 鉴权失败、超时、保存失败、重开 | +| 知识 | 粘贴文本/TXT、段落检索、引用、停用 | 空文档、编码错误、长文、停用后旧建议 | +| 标注 | 选择微信窗口、四区域保存、重载 | 缺失区域、窗口尺寸变化、DPI/多屏 | +| 引擎 | 启动检查、启动、暂停、停止、真实日志 | Agent 启动失败、窗口关闭、sidecar 退出 | +| 当前会话 | 手工布防、建立基线、从处理水位识别新文本、新消息合并替代 Run | 历史/已处理消息、未知期间新消息、相同文本、OCR 抖动、会话切换 | +| 建议与确认 | 冻结输入与知识版本引用、生成、编辑、批准、拒绝 | 配置/知识更新、新消息使旧建议失效、重复批准、旧 revision/contentHash | +| 发送 | 空输入框、完整写入、单击、UI 证据 | 原草稿、焦点漂移、回读不一致、点击后崩溃 | +| 未知结果 | 确认已发、确认未发、无法判断 | 应用重启、禁止自动重试、原 Outbox 不复活 | +| 本地持久化 | 重开恢复、schema 迁移 | 迁移失败、磁盘不可写、数据库不兼容 | + +只有上述主链全部通过,才能称为最小 MVP;其余页面完成度不影响本轮结论。 + +## 9. 最终结论 + +当前项目是“界面覆盖较完整、少量原生能力接线的桌面原型”,不是已经可用的 Windows 微信自动回复产品。 + +最小 MVP 只保留现有功能能够形成的链路:**模型/提示词/托管设置 → 微信标注 → 引擎启动 → 手工布防当前会话 → 水位后的新文本识别 → 知识版本段落检索 → 建议回复 → 逐条人工确认 → 一次发送 → UI 证据或人工核对 → 推进处理水位 → 真实日志**。待确认与结果核对并入现有微信分身页,不新增一级页面;Skill、Employee、蒸馏、团队账号、跨设备能力和自动联系人发现均不进入本轮。 + +必要技术闭环仅用于保证上述现有功能真实可用:SQLite 持久化、统一标注契约、当前会话基线与处理水位、不可变知识版本、输入合并替代 Run、Outbox 原子执行权、`executionId` 条件回写、发送前复检、用户草稿保护、聚合状态同步、未知期间 pending 积累、未知结果不自动重试、异常恢复不自动补发,以及 Tauri 侧固定键鼠流程。这些是现有自动回复功能的执行条件,不是新增业务模块。 + +本报告不声明 Windows 视觉自动化、微信 RPA 或自动发送已生产可用;旧 Go/Python 捕获链路仍包含明显的 macOS 实验路径,最终能力必须由真实 Tauri Windows 端到端记录证明。 diff --git a/docs/page-prototype-wireframes.md b/docs/page-prototype-wireframes.md new file mode 100644 index 0000000..172ddce --- /dev/null +++ b/docs/page-prototype-wireframes.md @@ -0,0 +1,1087 @@ +# 未完成页面 ASCII 原型与交互方案 + +> 依据:`docs/page-completion-audit.md` +> 输出形式:Markdown + ASCII 低保真线框 +> 设计目标:延续当前程序的桌面窗口、顶部标题栏、绿色主操作、卡片列表、独立二级窗口和底部 Tab 结构,不重新设计已完成的屏幕区域标注页。 + +--- + +## 1. 原型范围 + +本方案覆盖尚未形成业务闭环的页面与缺失状态: + +| 分组 | 页面 | +|---|---| +| 主窗口补全 | 微信分身、知识库列表、Skill 市场、员工蒸馏 | +| 知识库 | 新增知识、知识详情 | +| Skill | 新增/编辑 Skill、Skill 详情 | +| 员工蒸馏 | 开始蒸馏、员工详情 | +| 系统配置 | 设置中心的 7 个分区 | +| 引擎工具 | 完整运行日志 | + +不重复设计已完成的 `/annotate` 标注画布。 + +--- + +## 2. 统一页面结构 + +### 2.1 主窗口壳 + +一级窗口按 Tauri 当前配置的 **500 × 900** 纵向比例绘制;以下线框强调窄宽、纵向滚动和单列卡片。二级窗口仍按现有宽屏尺寸绘制,不做调整。 + +```text +┌────────────────────────────────────────────┐ +│ 页面标题 [◐][⚙][↻][退出] │ +├────────────────────────────────────────────┤ +│ │ +│ │ +│ 当前页面内容区 │ +│ 单列卡片 / 纵向滚动 │ +│ │ +│ │ +│ │ +│ │ +├────────────────────────────────────────────┤ +│ [微信分身] [知识库] [Skill] [员工蒸馏] │ +└────────────────────────────────────────────┘ +``` + +### 2.2 二级窗口壳 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 二级页面标题 [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ 二级页面内容区 │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 通用交互约定 + +- `[主按钮]`:绿色,执行当前页面唯一主动作。 +- `[次按钮]`:浅色或描边,不抢占主动作。 +- `[危险]`:红色,操作前二次确认。 +- `●`:正常或已启用;`○`:未启用;`!`:异常;`…`:处理中。 +- 所有异步按钮点击后进入 `处理中` 并禁用,完成后显示成功或可恢复错误。 +- 有未保存修改时关闭窗口,弹出“保存草稿 / 放弃修改 / 继续编辑”。 +- 详情窗口必须携带实体 `id`,不能只传固定路由。 +- 页面首次打开统一包含:骨架加载、加载失败、空数据三个状态。 + +--- + +## 3. 微信分身主页面补全 + +**路由:** `/`,主 Tab“微信分身” +**目的:** 完成引擎启停、启动前浮层检查、标注和日志入口;首页不常驻展示运行环境或检查项目。 + +### 3.1 默认停止状态 + +```text +┌────────────────────────────────────────────┐ +│ 微信分身 [◐][⚙][↻][退出] │ +├────────────────────────────────────────────┤ +│ │ +│ │ +│ ╭──────────╮ │ +│ │ 引擎球体 │ │ +│ │ STOP │ │ +│ ╰──────────╯ │ +│ 微信引擎未运行 │ +│ 启动后等待并处理本地微信消息 │ +│ │ +│ [ 启动引擎 ] │ +│ [开始标注] [查看完整日志] │ +│ │ +│ ┌─ 运行日志 ──────────────── [清空] ────┐ │ +│ │ 12:30:01 等待启动 │ │ +│ │ │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ │ +├────────────────────────────────────────────┤ +│ [微信分身] [知识库] [Skill] [员工蒸馏] │ +└────────────────────────────────────────────┘ +``` + +### 3.2 启动检查浮层 + +```text +┌────────────────────────────────────────────┐ +│ 微信分身(背景变暗,不可操作) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 启动前检查 │ │ +│ │ 正在确认引擎启动所需项目 │ │ +│ ├──────────────────────────────────┤ │ +│ │ ✓ 大模型配置与连接 │ │ +│ │ ✕ 微信区域标注不完整 │ │ +│ │ ○ 微信窗口 未检查│ │ +│ ├──────────────────────────────────┤ │ +│ │ 启动已停止 │ │ +│ │ 请先完成联系人、聊天内容、输入框 │ │ +│ │ 和发送按钮区域标注。 │ │ +│ │ │ │ +│ │ [关闭] [去标注] [重新检查] │ │ +│ └──────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────┘ +``` + +### 3.3 运行状态 + +```text +┌────────────────────────────────────────────┐ +│ 微信分身 [◐][⚙][↻][退出] │ +├────────────────────────────────────────────┤ +│ │ +│ ╭──────────╮ │ +│ │ 引擎球体 │ │ +│ │ RUNNING │ │ +│ ╰──────────╯ │ +│ 微信引擎运行中 │ +│ 已处理 26 条消息 · 队列 0 │ +│ │ +│ [ 停止引擎 ] │ +│ [开始标注] [查看完整日志] │ +│ [暂停自动发送] │ +│ │ +│ ┌─ 最近日志 ────────────────────────────┐ │ +│ │ 12:38:21 新消息识别完成 │ │ +│ │ 12:38:22 等待人工确认 │ │ +│ └──────────────────────────────────────┘ │ +├────────────────────────────────────────────┤ +│ [微信分身] [知识库] [Skill] [员工蒸馏] │ +└────────────────────────────────────────────┘ +``` + +### 3.4 交互逻辑 + +| 操作 | 逻辑 | +|---|---| +| 页面打开 | 只读取并恢复引擎当前状态,不在首页常驻展示检查项目 | +| 启动引擎 | 点击后先打开模态浮层,并按“大模型配置与连接 → 区域标注 → 微信窗口”顺序检查 | +| 检查中 | 浮层锁定首页操作;当前项显示加载状态,尚未执行的项目显示“未检查” | +| 检查失败 | 立即停止检查和启动流程;失败项显示原因和修复入口,后续项目保持“未检查” | +| 重新检查 | 从第一项重新执行;不能从失败项跳过,也不能强制启动 | +| 全部通过 | 浮层显示“检查通过,正在启动”,继续启动视觉流和 Agent;启动成功后自动关闭浮层 | +| 启动失败 | 保留浮层并显示具体失败阶段;已启动的子进程按现有回滚逻辑停止 | +| 停止引擎 | 显示停止确认;停止中禁用重启和标注操作 | +| 重启 | 停止成功后重新打开同一检查浮层,通过检查后再启动 | +| 标注 | 打开现有真实标注流程;完成后重新读取区域 | +| 完整日志 | 打开 `/window/engine-logs` | +| 暂停自动发送 | 只暂停发送动作,不停止识别和建议回复;状态同步到托管设置 | + +--- + +## 4. 知识库主列表补全 + +**主 Tab:** 知识库 +**目的:** 在保留当前类型筛选和卡片结构的基础上,增加搜索、真实状态和任务反馈。 + +知识库仅使用 **SQLite 全文相似度搜索(FTS5/BM25)**。每条知识保存完整文本,不做向量化、不生成 embedding、不拆分内容切片;搜索结果以知识文档为单位返回。 + +```text +┌────────────────────────────────────────────┐ +│ 知识库 [◐][⚙][↻][退出] │ +├────────────────────────────────────────────┤ +│ [全部] [视频] [图片] [文件] [案例] [其他] │ +│ │ +│ [ 搜索标题、标签或内容摘要.............. ] │ +│ [状态▼] [刷新] │ +│ 共 24 项 · 生效 18 · 解析中 2 · 异常 1 │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 销售冠军高意向客户跟进案例 │ │ +│ │ 案例 · 私域成交 · 高意向 │ │ +│ │ v2.4 ● 已生效 │ │ +│ │ 更新 06-08 · 引用 128 · 全文 2.6 万字 │ │ +│ └──────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 产品培训视频 │ │ +│ │ v1.0 … 解析中 36% │ │ +│ │ ███████████░░░░░░░░ │ │ +│ └──────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 售后边界说明 │ │ +│ │ v1.2 ! 解析失败 │ │ +│ │ PDF 密码保护 [查看][重试]│ │ +│ └──────────────────────────────────────┘ │ +│ [ + ] │ +├────────────────────────────────────────────┤ +│ [微信分身] [知识库] [Skill] [员工蒸馏] │ +└────────────────────────────────────────────┘ +``` + +### 4.1 交互逻辑 + +- 类型、状态和搜索条件组合过滤;返回列表时保留过滤条件和滚动位置。 +- 搜索调用 SQLite FTS5/BM25,对标题、标签和完整正文执行文本相似度排序;结果以知识文档为单位。 +- 点击卡片携带 `knowledgeId` 打开知识详情。 +- 点击 FAB 打开新增知识窗口。 +- 解析中卡片订阅任务进度;失败卡片直接提供“查看原因”和“重试”。 +- 空态显示“暂无该类型知识”和“新增知识”按钮。 +- 状态优先级:`解析失败 > 解析中 > 待审核 > 已生效 > 已停用 > 草稿`。 + +--- + +## 5. 新增知识库原型 + +**路由:** `/window/knowledge-create` +**形式:** 4 步向导;每一步保留草稿。 + +### 5.1 步骤 1:基本信息 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 新增知识库 [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ ① 基本信息 ─── ② 来源导入 ─── ③ 全文预览 ─── ④ 发布设置 │ +│ │ +│ 知识标题 * │ +│ [ 例如:售后退款政策............................................ ] │ +│ │ +│ 知识类型 * │ +│ ( ) 文件 ( ) 图片 ( ) 视频 ( ) 案例 ( ) 其他 │ +│ │ +│ 标签 │ +│ [ 私域成交 × ] [ 售后 × ] [ 输入标签后回车....................... ] │ +│ │ +│ 适用范围 │ +│ [ 全部员工 ▼ ] │ +│ │ +│ 说明 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 说明知识用途、适用场景和限制 │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ [保存草稿] [下一步:导入] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 步骤 2:来源导入 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ ① 基本信息 ─── ② 来源导入 ─── ③ 全文预览 ─── ④ 发布设置 │ +│ │ +│ 导入方式: [上传文件] [粘贴文本] [选择本地目录] │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 拖拽文件到此处 │ │ +│ │ 支持 PDF / DOCX / TXT / PNG / JPG / MP4 │ │ +│ │ [选择文件] │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 已选择 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ refund-policy.pdf 2.4 MB 等待解析 [移除] │ │ +│ │ service-example.docx 860 KB 等待解析 [移除] │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ☑ 导入前对手机号、订单号等敏感信息进行脱敏 │ +│ │ +│ [上一步] [保存草稿] [开始解析] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 5.3 步骤 3:全文预览 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ ① 基本信息 ─── ② 来源导入 ─── ③ 全文预览 ─── ④ 发布设置 │ +│ │ +│ 解析进度 72% 正在执行 OCR │ +│ ████████████████████░░░░░░░░ │ +│ │ +│ 文件 2 · 成功 1 · 处理中 1 · 失败 0 [取消解析] │ +│ │ +│ [refund-policy.pdf] [service-example.docx] │ +│ ┌─ 完整文本预览 ─────────────────────────────────────────────────┐ │ +│ │ 第一章 退款申请范围 │ │ +│ │ 退款申请需在订单完成后 7 日内提交。已使用的数字商品不支持…… │ │ +│ │ │ │ +│ │ 第二章 异常订单处理 │ │ +│ │ 客户情绪激动时先确认问题,再说明退款边界…… │ │ +│ │ [编辑全文] │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ SQLite 搜索字段:标题 + 标签 + 完整正文 · 不切片 │ +│ │ +│ [上一步] [保存草稿] [重新解析] [下一步:发布设置] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 5.4 步骤 4:发布设置 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ ① 基本信息 ─── ② 来源导入 ─── ③ 全文预览 ─── ④ 发布设置 │ +│ │ +│ 发布摘要 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 标题:售后退款政策 │ │ +│ │ 来源:2 个文件 · 完整正文 2.6 万字 · 已脱敏 │ +│ │ 预计影响:3 个 Skill、2 个员工 │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 发布方式 │ +│ (●) 立即生效 ( ) 保存为待审核 ( ) 仅保存草稿 │ +│ │ +│ 版本说明 │ +│ [ 首次导入退款政策.............................................. ] │ +│ │ +│ ☑ 我已确认内容不包含未经授权的个人敏感信息 │ +│ │ +│ [上一步] [保存草稿] [发布知识] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 5.5 交互逻辑 + +1. 标题、类型为必填;校验失败时定位到第一个错误字段。 +2. 文件导入后生成本地任务;关闭窗口不取消任务。 +3. 解析失败保留成功文件,允许只重试失败文件。 +4. 每个来源文件保存完整正文,可编辑全文;至少存在 1 份非空正文才能发布。 +5. “保存草稿”在任一步可用;再次进入时恢复步骤、文件和编辑内容。 +6. 发布成功后关闭窗口,主列表新增对应卡片并定位到该项。 + +--- + +## 6. 知识详情原型 + +**路由:** `/window/knowledge-detail?id=` + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 知识库详情 [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ 售后常见问题与退款边界 v1.7 ● 已生效 [更多▼] │ +│ 文件 · 售后 · 更新于 2026-06-05 · 引用 82 次 │ +│ │ +│ [概览] [全文内容] [引用关系 5] [版本历史 7] [错误记录] │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 基本信息 [编辑基本信息] │ +│ ┌──────────────────────────────┬─────────────────────────────────┐ │ +│ │ 来源:refund-policy.pdf │ 解析方式:PDF + OCR │ │ +│ │ 创建人:本机管理员 │ 当前版本:v1.7 │ │ +│ │ 生效时间:2026-06-05 │ 最近检索:12 分钟前 │ │ +│ └──────────────────────────────┴─────────────────────────────────┘ │ +│ │ +│ 内容摘要 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 涵盖退款期限、数字商品限制、异常订单处理和安抚话术边界…… │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 使用情况 │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │ +│ │ 本周命中 32 次 │ │ 引用 Skill 3 个│ │ 绑定员工 2 个 │ │ +│ └────────────────┘ └────────────────┘ └────────────────────────┘ │ +│ │ +│ [重新解析] [创建新版本] [停用] [删除知识] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 6.1 标签页内容 + +- **全文内容**:展示和搜索完整正文,可编辑全文;变更后创建新版本。 +- **引用关系**:列出引用该知识的 Skill 和员工;点击打开对应详情。 +- **版本历史**:版本号、发布时间、操作者、变更说明;支持比较和回滚。 +- **错误记录**:解析、检索、权限错误;支持复制错误和重新处理。 + +### 6.2 交互逻辑 + +| 操作 | 逻辑 | +|---|---| +| 编辑基本信息 | 标题、标签、说明可编辑;保存生成审计记录,不创建内容版本 | +| 重新解析 | 先显示影响说明;成功后创建新草稿版本,确认后替换生效版本 | +| 创建新版本 | 复制当前版本进入编辑,不直接覆盖线上版本 | +| 停用 | 二次确认;停用后检索不再命中,但历史引用保留 | +| 删除 | 先检查引用关系;存在引用时禁止硬删除,提示先解绑或仅停用 | +| 回滚 | 以历史版本内容创建一个新的当前版本,历史记录不删除 | + +--- + +## 7. Skill 市场主列表补全 + +```text +┌────────────────────────────────────────────┐ +│ Skill 市场 [◐][⚙][↻][退出] │ +├────────────────────────────────────────────┤ +│ [全部] [内置] [自定义] │ +│ [ 搜索 Skill 名称或能力................. ] │ +│ [状态▼] [刷新] │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 客户意图识别 │ │ +│ │ 内置 · v2.1 ● 已启用 │ │ +│ │ 线索分级 异议判断 下一步建议 │ │ +│ │ 绑定知识 3 · 最近调用 16 次 │ │ +│ │ [查看][开关●] │ │ +│ └──────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 报价策略生成 │ │ +│ │ 自定义 · v1.3 ● 已启用 │ │ +│ │ 利润保护 阶梯报价 优惠边界 │ │ +│ │ 测试通过 · 修改于 2 天前 │ │ +│ │ [编辑][开关●] │ │ +│ └──────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 售后安抚助手 │ │ +│ │ 内置 · v1.0 ○ 已关闭 │ │ +│ │ 情绪识别 补偿建议 工单摘要 │ │ +│ │ [查看][开关○] │ │ +│ └──────────────────────────────────────┘ │ +│ [ + ] │ +├────────────────────────────────────────────┤ +│ [微信分身] [知识库] [Skill] [员工蒸馏] │ +└────────────────────────────────────────────┘ +``` + +### 7.1 交互逻辑 + +- 点击卡片或“查看/编辑”打开具体 Skill 详情,必须携带 `skillId`。 +- 内置 Skill 只读,只允许启停和查看权限。 +- 自定义 Skill 可编辑;编辑已发布 Skill 时先创建草稿版本。 +- 启用前检查测试状态和权限完整性;不通过时跳转到对应配置项。 +- 开关采用等待后端成功再更新,失败时保留原状态并显示原因。 + +--- + +## 8. 新增/编辑 Skill 原型 + +**路由建议:** + +- 新增:`/window/skill-create` +- 详情:`/window/skill-detail?id=` + +### 8.1 编辑页 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 新增 Skill [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ [基本信息] [执行定义] [权限授权] [沙箱测试] [发布设置] │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ Skill 名称 * │ +│ [ 报价策略生成.................................................. ] │ +│ │ +│ 类型 版本 │ +│ [ 自定义 ▼ ] [ v1.0 ] │ +│ │ +│ 描述 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 根据客户意向、商品成本和优惠边界生成报价建议 │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 能力标签 │ +│ [ 利润保护 × ] [ 阶梯报价 × ] [ 输入标签......................... ] │ +│ │ +│ [保存草稿] [下一步:定义] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 8.2 执行定义 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 系统提示词 [恢复默认] │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 你是报价策略助手。不得突破配置的最低利润率…… │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 输入 Schema 输出 Schema │ +│ ┌──────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ customerLevel: string │ │ suggestedPrice: number │ │ +│ │ productId: string │ │ reason: string │ │ +│ │ maxDiscount: number │ │ risk: string │ │ +│ └──────────────────────────────┘ └─────────────────────────────┘ │ +│ │ +│ 可调用工具 │ +│ ☑ 查询商品信息 ☑ 检索知识库 ☐ 发送微信消息 ☐ 创建工单 │ +│ │ +│ [上一步] [保存草稿] [下一步:授权] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 8.3 权限授权 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 可访问知识库 [搜索知识] [全不选] │ +│ ☑ 产品价格表 已生效 v3.2 │ +│ ☑ 报价与折扣边界 已生效 v1.4 │ +│ ☐ 售后退款政策 已生效 v1.7 │ +│ │ +│ 危险权限 │ +│ ☐ 允许直接发送消息 │ +│ 未授权时只生成建议,必须由人工确认。 │ +│ ☐ 允许主动联系客户 │ +│ 开启后仍受托管配置和白名单限制。 │ +│ │ +│ [上一步] [保存草稿] [下一步:测试] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 8.4 沙箱测试 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 测试输入 [载入样例▼] │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 客户等级:A │ │ +│ │ 商品:年度专业版 │ │ +│ │ 客户问题:还能再优惠一点吗? │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ [运行测试] │ +│ ───────────────────────────────────────────────────────────────── │ +│ ● 测试通过 · 1.8s · 820 tokens │ +│ │ +│ 输出结果 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 建议报价:¥8,800 │ │ +│ │ 理由:客户等级 A,可使用 8% 优惠,仍高于最低利润线…… │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 工具轨迹 │ +│ 1. 查询商品信息 ✓ 2. 检索报价边界 ✓ 3. 生成建议 ✓ │ +│ │ +│ [上一步] [保存测试样例] [下一步:发布] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 8.5 发布规则 + +- 必须完成至少一次成功测试,才能发布或启用。 +- 输出不符合 schema、工具越权或超时均视为测试失败。 +- 危险权限单独确认,并说明仍受托管配置控制。 +- 发布后版本不可直接覆盖;继续编辑生成下一个草稿版本。 +- 内置 Skill 详情使用同一结构,但隐藏编辑和发布按钮。 + +--- + +## 9. 员工蒸馏主页面补全 + +```text +┌────────────────────────────────────────────┐ +│ 员工蒸馏 [◐][⚙][↻][退出] │ +├────────────────────────────────────────────┤ +│ 把优秀话术沉淀成可评估、可授权的员工能力 │ +│ [查看教程] [开始新的蒸馏] │ +│ │ +│ 当前任务 │ +│ ┌──────────────────────────────────────┐ │ +│ │ 销售冠军 Aileen 话术蒸馏 │ │ +│ │ … 能力提取 72% │ │ +│ │ ███████████████████░░░░░ │ │ +│ │ 成功 248 · 失败 7 · 剩余约 3 分钟 │ │ +│ │ [查看][取消]│ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ 已发布员工 │ +│ [搜索员工................] [状态▼] │ +│ ┌──────────────────────────────────────┐ │ +│ │ 销售冠军 Aileen v3.2 ● 已启用 │ │ +│ │ 企微私域成交 │ │ +│ │ 高意向逼单 / 异议拆解 / 温和推进 │ │ +│ │ 评分 96 · 本周调用 28 [查看详情]│ │ +│ └──────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 客服主管 Mina v2.1 ○ 已关闭 │ │ +│ │ 售后服务 │ │ +│ │ 投诉降温 / 售后边界 / 情绪安抚 │ │ +│ │ [查看详情]│ │ +│ └──────────────────────────────────────┘ │ +├────────────────────────────────────────────┤ +│ [微信分身] [知识库] [Skill] [员工蒸馏] │ +└────────────────────────────────────────────┘ +``` + +### 9.1 交互逻辑 + +- “查看教程”打开标注清楚的帮助页,不使用无响应按钮。 +- 任务进度来自 `DistillJob`,页面重开后继续显示。 +- 点击任务进入任务详情,可查看当前阶段和失败样本。 +- 取消任务需确认;已完成阶段数据保留,可稍后继续。 +- 点击员工卡片携带 `employeeId` 打开员工详情。 + +--- + +## 10. 开始蒸馏原型 + +**路由:** `/window/distill-start` +**形式:** 5 步向导。 + +### 10.1 步骤总览 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 开始蒸馏 [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ ① 来源导入 ─ ② 隐私处理 ─ ③ 能力提取 ─ ④ 效果评估 ─ ⑤ 发布员工 │ +│ │ +│ 数据来源 │ +│ [导入微信聊天] [上传文档] [选择已有案例] │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ sales-chat-2026-06.json 1,286 条会话 ✓ 格式有效 │ │ +│ │ champion-scripts.docx 38 条话术 ✓ 格式有效 │ │ +│ │ follow-up-cases 12 个已有案例 ✓ 已选择 │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据范围 │ +│ 时间:[2026-01-01] 至 [2026-06-30] 最少有效会话:50 条 │ +│ │ +│ [保存草稿] [下一步:隐私检查] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 10.2 隐私处理 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 扫描结果:发现 423 个联系人、86 个手机号、31 个订单号 │ +│ │ +│ 脱敏规则 │ +│ ☑ 联系人名称替换为匿名编号 │ +│ ☑ 手机号保留后四位 │ +│ ☑ 订单号完全隐藏 │ +│ ☑ 地址只保留省市 │ +│ │ +│ 脱敏预览 │ +│ 原文:张三的订单 202606180018,手机号 13800138000 │ +│ 结果:客户-023 的订单 ************,手机号 *******8000 │ +│ │ +│ ☑ 我确认仅处理已获授权的数据 │ +│ │ +│ [上一步] [保存草稿] [开始能力提取] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 10.3 能力提取 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 正在提取能力 72% │ +│ ████████████████████░░░░░░░░ │ +│ │ +│ ✓ 数据清洗 1,286 / 1,286 │ +│ ✓ 场景聚类 18 个场景 │ +│ … 策略提取 13 / 18 │ +│ ○ 边界归纳 等待 │ +│ ○ 反例生成 等待 │ +│ │ +│ 已识别能力 │ +│ [高意向识别] [异议拆解] [温和推进] [优惠边界] [长期跟进] │ +│ │ +│ 失败样本 7 条 [查看失败样本] │ +│ │ +│ [后台继续运行] [取消任务] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 10.4 效果评估 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 综合评分 92 / 100 ● 达到发布标准 │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 场景命中 94% │ │ 拒答准确 91% │ │ 越权率 0.3% │ │ 一致性 89% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ 测试样例 │ +│ ✓ 高意向询价 建议合理 │ +│ ✓ 超边界优惠 已拒绝越权 │ +│ ! 售后退款混合场景 回答包含不确定信息 [查看] │ +│ │ +│ [重新评估] [导出评估报告] [下一步:发布] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 10.5 发布员工 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 员工名称 * │ +│ [ 销售冠军 Aileen............................................... ] │ +│ │ +│ 业务领域 初始版本 │ +│ [ 企微私域成交 ▼ ] [ v1.0 ] │ +│ │ +│ 授权能力 │ +│ ☑ 客户意图识别 ☑ 报价策略生成 ☐ 售后安抚助手 │ +│ │ +│ 授权知识 │ +│ ☑ 产品价格表 ☑ 报价边界 ☑ 高意向跟进案例 │ +│ │ +│ 初始状态 │ +│ ( ) 发布后立即启用 (●) 发布但保持关闭 │ +│ │ +│ [保存草稿] [上一步] [发布员工] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 10.6 交互逻辑 + +- 从能力提取开始创建后台任务,关闭窗口后任务继续。 +- “后台继续运行”关闭窗口并在员工蒸馏主页面展示任务卡片。 +- 失败样本允许排除、修正分类或重新处理,但保留审计记录。 +- 评估未达阈值时禁用发布,提供“查看失败项”和“重新提取”。 +- 发布成功后跳转员工详情,并同步主列表。 + +--- + +## 11. 员工详情原型 + +**路由:** `/window/employee-detail?id=` + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 员工详情 [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ 销售冠军 Aileen v3.2 ● 已启用 [启停开关●] │ +│ 企微私域成交 · 更新于 2026-06-18 │ +│ │ +│ [概览] [能力与授权] [评估报告] [版本历史] [调用记录] │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 员工画像 [编辑画像] │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 高意向逼单 / 异议拆解 / 温和推进 │ │ +│ │ 适用:已有明确需求、处于报价或决策阶段的私域客户 │ │ +│ │ 禁止:承诺未授权折扣、绕过人工确认直接发送敏感信息 │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 综合评分 96 │ │ 本周调用 28 │ │ 采纳率 82% │ │ 异常 1 次 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ 绑定能力:客户意图识别、报价策略生成 [管理授权] │ +│ 绑定知识:产品价格表、报价边界、高意向案例 [管理知识] │ +│ │ +│ [重新蒸馏] [复制为新员工] [停用] [更多操作▼] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 11.1 交互逻辑 + +| 操作 | 逻辑 | +|---|---| +| 启停 | 启用前校验模型、Skill、知识权限;停用不删除历史 | +| 管理授权 | 弹出 Skill 多选,危险权限额外确认 | +| 管理知识 | 只显示已生效知识;停用知识标为不可用 | +| 重新蒸馏 | 创建新蒸馏任务,当前版本继续运行,直到新版本发布 | +| 版本回滚 | 基于历史版本创建新当前版本,不修改历史记录 | +| 查看异常 | 跳转调用记录并过滤异常项,可关联 requestId 查看日志 | + +--- + +## 12. 设置中心原型 + +**路由:** `/window/settings` +**布局:** 延续当前左侧分类、右侧内容。底部统一显示未保存状态和保存按钮。 + +### 12.1 设置壳 + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 设置 [主题] [关闭] │ +├──────────────────┬───────────────────────────────────────────────────┤ +│ 基础配置 │ 当前分区标题 │ +│ 提示词配置 │ 分区说明 │ +│ 回复规则 │ │ +│ Skill 管理 │ 配置内容 │ +│ 员工管理 │ │ +│ 知识库管理 │ │ +│ 托管配置 │ │ +│ ├───────────────────────────────────────────────────┤ +│ │ ● 有未保存修改 [恢复] [保存配置] │ +└──────────────────┴───────────────────────────────────────────────────┘ +``` + +### 12.2 基础配置 + +```text +┌─ 大模型配置 ─────────────────────────────────────────────────────────┐ +│ 模型厂家 [ 豆包 / 火山引擎 ▼ ] │ +│ 请求地址 [ https://ark.cn-beijing.volces.com/api/v3......... ] │ +│ API Key [ ● 已配置 ************************ ] [更换] [删除] │ +│ 模型名称 [ doubao-pro-32k ▼ ] │ +│ 请求超时 [ 8000 ] ms │ +│ │ +│ [测试连接] ● 连接成功 · 324ms · doubao-pro-32k │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +交互:厂家变化时加载默认地址和可选模型;不覆盖用户已编辑值,先确认。测试连接使用当前表单值,不要求先保存。API Key 只显示“已配置”,不回显明文。 + +### 12.3 提示词配置 + +```text +┌─ 固定提示词 ─────────────────────────────────────────── [不可修改] ─┐ +│ 你是本地微信分身助手,需要遵守托管策略、回复边界与用户授权…… │ +└───────────────────────────────────────────────────────────────────────┘ + +┌─ 自定义提示词 ─────────────────────────── 82 / 4000 [恢复默认] ─────┐ +│ 保持友好、简洁、可信赖。优先调用知识库和已授权员工能力…… │ +│ │ +└───────────────────────────────────────────────────────────────────────┘ +[预览组合提示词] [保存提示词] +``` + +交互:固定提示词真正只读;自定义提示词保存前校验长度;恢复默认需确认;离开分区时提示未保存修改。 + +### 12.4 回复规则 + +```text +┌─ 基础规则 ───────────────────────────────────────────────────────────┐ +│ 免打扰模式 [开关○] [时间段 22:00 - 08:00] │ +│ 自动加好友 [开关○] │ +│ 主动联系客户 [开关○] 需要白名单授权 │ +│ 开启蒸馏 [开关●] │ +└───────────────────────────────────────────────────────────────────────┘ + +┌─ 用户名单 ───────────────────────────────────────────────────────────┐ +│ [白名单] [黑名单] │ +│ [ 搜索或输入微信名称.............................. ] [添加] │ +│ 张三 来源:手动添加 2026-06-20 [移除] │ +│ 客户测试群 来源:联系人选择 2026-06-18 [移除] │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +交互:黑白名单使用列表而非开关;同一用户不能同时存在于两个名单。主动联系开启前必须至少存在一个白名单用户。 + +### 12.5 Skill 管理 + +```text +┌─ Skill 功能 ─────────────────────────────────────────────────────────┐ +│ 开启 Skill 功能 [总开关●] │ +│ 关闭总开关后保留以下单项配置,但运行时全部不生效。 │ +├───────────────────────────────────────────────────────────────────────┤ +│ 客户意图识别 内置 v2.1 已测试 [开关●] │ +│ 报价策略生成 自定义 v1.3 已测试 [开关●] │ +│ 售后安抚助手 内置 v1.0 未测试 [开关○] │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +### 12.6 员工管理 + +```text +┌─ 员工能力 ───────────────────────────────────────────────────────────┐ +│ 开启使用员工功能 [总开关●] │ +├───────────────────────────────────────────────────────────────────────┤ +│ 销售冠军 Aileen v3.2 评分 96 企微私域成交 [开关●] │ +│ 售前专家 Leo v1.8 评分 91 B2B 咨询 [开关●] │ +│ 客服主管 Mina v2.1 评分 88 售后服务 [开关○] │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +### 12.7 知识库管理 + +```text +┌─ SQLite 文本搜索 ────────────────────────────────────────────────────┐ +│ 开启使用知识库功能 [总开关●] │ +│ 搜索算法 SQLite FTS5 / BM25(固定) │ +│ 最大返回数量 [ 5 ] │ +│ 搜索字段 ☑ 标题 ☑ 标签 ☑ 完整正文 │ +├───────────────────────────────────────────────────────────────────────┤ +│ 售后退款政策 v1.7 已生效 正文 2.6 万字 [开关●] │ +│ 产品价格表 v3.2 已生效 正文 8,200 字 [开关●] │ +│ 旧版报价说明 v0.9 已停用 [不可启用] │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +### 12.8 托管配置 + +```text +┌─ 托管总开关 ─────────────────────────────────────────────────────────┐ +│ 开启托管功能 [开关○] │ +│ 开启后 Agent 可在规则和授权范围内处理微信消息。 │ +└───────────────────────────────────────────────────────────────────────┘ + +┌─ 托管策略 ───────────────────────────────────────────────────────────┐ +│ 本地消息服务 [开关●] │ +│ 发送前人工确认 [开关●] │ +│ 异常自动暂停 [开关●] │ +│ 连续异常阈值 [ 3 ] 次 │ +│ 单日消息最长等待 [ 30 ] 秒 │ +└───────────────────────────────────────────────────────────────────────┘ + +优先级:异常自动暂停 > 人工确认 > 自动发送 > 托管总开关 +``` + +### 12.9 设置统一交互逻辑 + +1. 页面打开调用 `get_config`;加载失败显示重试,不展示伪默认值。 +2. 修改任意字段后出现“有未保存修改”。 +3. 切换分区不丢失草稿;关闭窗口时提示保存或放弃。 +4. 保存使用一次事务提交,失败时保留所有表单内容。 +5. 总开关关闭时,单项开关保留但显示为暂不生效。 +6. 保存成功通过事件同步其他窗口的模型、引擎和托管状态。 + +--- + +## 13. 完整运行日志原型 + +**路由:** `/window/engine-logs` + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 分身引擎日志 [主题] [关闭] │ +├──────────────────────────────────────────────────────────────────────┤ +│ [全部▼] [模块▼] [ 搜索消息或 requestId......................... ] │ +│ ☑ 自动滚动 [暂停接收] [导出] [清空视图] │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ 12:30:01 INFO agent 引擎启动成功 │ │ +│ │ 12:30:02 INFO vision 预览流已连接,12.4 FPS │ │ +│ │ 12:30:06 DEBUG capture 检测到客户 A 新消息 │ │ +│ │ 12:30:07 INFO retriever 命中“报价边界” score=0.86 │ │ +│ │ 12:30:08 INFO model 回复生成完成 requestId=req_91fa │ │ +│ │ 12:30:09 WARN policy 等待人工确认 │ │ +│ │ 12:30:15 ERROR sender 发送失败:当前会话已切换 │ │ +│ │ │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ 已显示 286 / 1,240 条 · 缓存上限 10,000 · 最后更新 12:30:15 │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 13.1 日志详情抽屉 + +```text +┌─ 日志详情 ───────────────────────────────────────────────────────────┐ +│ 时间 2026-07-20 12:30:15.238 │ +│ 级别 ERROR │ +│ 模块 sender │ +│ requestId req_91fa [复制] │ +│ 消息 发送失败:当前会话已切换 │ +│ 上下文 snapshot-9821 → snapshot-9822 │ +│ │ +│ [复制完整日志] [仅筛选此 requestId] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 13.2 交互逻辑 + +- 实时订阅 `engine-log`;暂停接收时后端继续运行,前端暂存新日志数量。 +- 筛选条件包括级别、模块和搜索文本,可组合使用。 +- 点击日志行打开详情;requestId 可串联同一次处理流程。 +- “清空视图”只清当前显示缓存,不删除磁盘日志;执行后提供“重新加载”。 +- “导出”按当前筛选导出文本或 JSON。 +- 列表缓存 10,000 条,超过后淘汰最旧数据;采用虚拟滚动。 + +--- + +## 14. 跨页面交互关系 + +```text +微信分身 + ├─ 启动检查失败:模型配置 ───────────────> 设置 / 基础配置 + ├─ 启动检查失败:标注不完整 ─────────────> 真实标注页 + ├─ 启动检查失败:未找到微信窗口 ─────────> 关闭浮层并重新打开微信 + └─ 检查全部通过 ─────────────────────────> 自动继续启动引擎 + +知识库列表 + ├─ 新增 ─────────────────────────────────> 新增知识四步向导 + └─ 点击卡片 ─────────────────────────────> 知识详情(id) + +Skill 市场 + ├─ 新增 ─────────────────────────────────> Skill 编辑器 + └─ 点击卡片 ─────────────────────────────> Skill 详情(id) + +员工蒸馏 + ├─ 开始新的蒸馏 ─────────────────────────> 蒸馏五步向导 + ├─ 点击任务 ─────────────────────────────> 蒸馏任务当前步骤 + └─ 点击员工 ─────────────────────────────> 员工详情(id) + +``` + +--- + +## 15. 全局反馈原型 + +### 15.1 异步处理中 + +```text +[ … 正在保存 ] 按钮禁用,不允许重复提交 +``` + +### 15.2 成功提示 + +```text +┌──────────────────────────────────────┐ +│ ✓ 配置已保存 │ +│ 已同步到运行中的引擎 │ +└──────────────────────────────────────┘ +``` + +### 15.3 可恢复错误 + +```text +┌──────────────────────────────────────────────────────────┐ +│ ! 模型连接超时 │ +│ 请求地址无响应,请检查网络或将超时时间调大。 │ +│ [去修改] [重新测试] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 15.4 未保存关闭确认 + +```text +┌──────────────────────────────────────────────┐ +│ 关闭前保存修改? │ +│ 当前页面有尚未保存的内容。 │ +│ │ +│ [继续编辑] [放弃修改] [保存并关闭] │ +└──────────────────────────────────────────────┘ +``` + +### 15.5 危险操作确认 + +```text +┌──────────────────────────────────────────────┐ +│ 停用“售后退款政策”? │ +│ 停用后引擎不会再检索该知识,历史记录仍保留。 │ +│ │ +│ [取消] [确认停用] │ +└──────────────────────────────────────────────┘ +``` + +--- + +## 16. 页面状态检查清单 + +每个页面实现时至少需要对应以下原型状态: + +| 状态 | 页面表现 | +|---|---| +| 首次加载 | 内容骨架或明确的加载文案,禁止先显示假数据 | +| 空数据 | 解释为什么为空,并提供创建或刷新动作 | +| 加载失败 | 显示原因摘要、重试和相关设置/日志入口 | +| 提交中 | 主按钮禁用并显示阶段,不允许重复提交 | +| 提交成功 | 更新当前内容或返回来源列表,并提供明确反馈 | +| 提交失败 | 保留输入,展示原因和恢复方式 | +| 未保存 | 标记 dirty;关闭、返回或切换实体时确认 | +| 权限不足 | 解释缺失权限,并提供授权入口 | +| 后台任务 | 可离开、可恢复、可查看进度、可取消或重试 | +| 实体失效 | id 不存在或已删除时显示说明并返回列表 | + +--- + +## 17. 推荐实现顺序 + +1. **先补主链路入口和可信状态**:微信分身状态、日志入口、实体 id。 +2. **完成知识库闭环**:知识列表 → 新增 → 解析 → 详情 → 发布/停用。 +3. **完成设置闭环**:配置读取、表单状态、连接测试、事务保存。 +4. **完成 Skill 闭环**:列表 → 编辑 → 测试 → 授权 → 发布。 +5. **完成蒸馏闭环**:任务向导 → 后台进度 → 评估 → 员工详情。 +6. **最后完善日志诊断**:实时订阅、筛选、导出和 requestId 串联。 + +实现时保持当前组件和视觉语言,不再新增另一套页面壳、按钮体系或导航方式。 diff --git a/docs/src-ui-design-summary.md b/docs/src-ui-design-summary.md new file mode 100644 index 0000000..c1fe61d --- /dev/null +++ b/docs/src-ui-design-summary.md @@ -0,0 +1,624 @@ +# 页面设计方案总结(适合直接转 HTML + CSS) + +本文只保留可直接迁移到 **纯 HTML + CSS** 的内容: + +- 配色方案 +- 布局方案 +- 页面关系 +- 组件关系 +- 常用视觉模块 +- 页面复刻建议 + +目标:你可以把它当成一份视觉与结构设计说明,在别的目录下直接手写 HTML + CSS 实现。 + +--- + +## 1. 整体设计气质 + +这套界面的核心气质是: + +- **桌面端工具型产品** +- **微信绿色主品牌色** +- **大圆角卡片化布局** +- **顶部毛玻璃 / 半透明工具栏** +- **浅层次背景,不走厚重拟物** +- **深浅双主题** +- **日志、状态、工作台等模块有明显的功能分区** + +更适合的产品类型: + +- AI 助手桌面端 +- 本地工作台 +- 控制台 / 监控台 +- 多模块业务工具 +- 轻后台但不是传统表格后台 + +一句话概括: + +> 微信绿色品牌风格 + 桌面端工作台布局 + 卡片化轻玻璃界面。 + +--- + +## 2. 配色方案 + +### 2.1 Light 主题主色板 + +| 语义 | 色值 | 用途 | +|---|---:|---| +| 页面背景 | `#f7f8f7` | 最外层大背景 | +| 页面白底 | `#ffffff` | 主内容区域 / 卡片主底 | +| 次级表面 | `#f2f5f3` | 次级块、顶部条、浅灰面 | +| 主文字 | `#151716` | 标题、正文 | +| 次文字 | `#66716c` | 说明、描述 | +| 弱文字 | `#909a95` | 辅助信息、占位 | +| 分隔线 | `#dde4df` | 卡片边线、容器边线 | +| 强分隔线 | `#cad5cf` | 输入框边线等更强调场景 | +| 主品牌色 | `#07c160` | 主按钮、选中态、关键状态 | +| 主品牌高亮 | `#10d978` | 渐变高光 / 装饰层 | +| 主品牌浅底 | `#e6f7ee` | 选中背景、绿色浅底 | +| 次主品牌浅底 | `#f1fbf6` | 更轻的绿色背景 | +| 强调蓝 | `#2d8cff` | 辅助强调、截图框、信息辅助 | +| 警告橙 | `#fa8c16` | 版本、警告状态 | +| 错误红 | `#f5222d` | 停用、错误、危险操作 | + +### 2.2 Dark 主题主色板 + +| 语义 | 色值 | 用途 | +|---|---:|---| +| 页面背景 | `#090d0b` | 深色底 | +| 页面主底 | `#0d1110` | 应用主底 | +| 一级表面 | `#121817` | 卡片 | +| 二级表面 | `#18211e` | 顶栏、次级分块 | +| 主文字 | `#f4f7f5` | 主要标题 / 内容 | +| 次文字 | `#aab5af` | 说明文本 | +| 弱文字 | `#7f8c85` | 辅助信息 | +| 分隔线 | `#24302b` | 轻边线 | +| 强分隔线 | `#34423c` | 强边线 | +| 主品牌色 | `#07c160` | 不变,保持品牌识别 | +| 主品牌高亮 | `#0ed46e` | 深色模式高亮层 | +| 主品牌浅底 | `#0d2a1b` | 深色下选中底 | +| 次主品牌浅底 | `#10251a` | 更弱的绿色底 | +| 强调蓝 | `#2d8cff` | 信息强调 | +| 警告橙 | `#fa8c16` | 警告 | +| 错误红 | `#ff4d4f` | 错误 | + +### 2.3 配色使用原则 + +1. **绿色只给主动作和主状态** + - 主按钮 + - 选中状态 + - 正向结果 + - 关键进度 + +2. **蓝色做辅助强调,不抢主色** + - 辅助信息 + - 说明型标注 + - 选区边框 / overlay + +3. **橙色用于版本号、警告、待处理** + +4. **红色只用于停用、错误、危险动作** + +5. **背景层次通过 page / surface / surface-2 完成,不要随便加很多新灰色** + +--- + +## 3. 圆角、边框、阴影规则 + +### 3.1 圆角体系 + +当前设计里圆角偏大,建议统一: + +| 场景 | 推荐圆角 | +|---|---:| +| 主窗口外壳 | `12px` | +| 通用卡片 panel | `18px` | +| 次级块 / 输入 / 小面板 | `8px` ~ `14px` | +| 胶囊按钮 / 主按钮 | `999px`(全圆) | +| 状态标签 / Tag | `999px`(全圆) | +| 浮动按钮 FAB | `999px`(全圆) | + +整体风格偏“柔和圆角”,不是锐角矩形。 + +### 3.2 边框规则 + +- 卡片、列表项、输入框、标签大多都有一层轻边线 +- 边框颜色不重,强调的是结构层次,不是视觉攻击性 +- 建议所有卡片默认带边框,而不是全靠阴影分层 + +### 3.3 阴影规则 + +主要阴影偏柔和、大范围扩散: + +- 通用 panel:轻投影 +- 主按钮:带绿色发光感阴影 +- 主视觉球体 / 引擎区域:更明显的大阴影 +- 不建议到处堆重阴影 + +设计原则: + +> 阴影是辅助层次,不是主视觉主角。 + +--- + +## 4. 背景与材质方案 + +### 4.1 主壳背景 + +主壳不是纯白/纯黑背景,而是: + +- 多层 radial gradient +- 少量品牌色气氛染色 +- 再叠一层半透明 page 背景 + +也就是说,窗口背景应该有一点品牌感,不要做成“普通表单系统”。 + +### 4.2 毛玻璃顶栏 + +顶部条采用: + +- 半透明渐变背景 +- 模糊滤镜 `blur` +- 饱和度增强 +- 轻阴影 + +适合作为: + +- 顶部标题栏 +- 轻工具栏 +- 分页工具条 + +### 4.3 终端面板材质 + +日志区和终端区单独走暗色质感: + +- 深色渐变背景 +- 终端字体配色 +- 信息等级分色 + +这类模块和普通卡片要明确区分,不能混成一个系统。 + +--- + +## 5. 页面布局方案 + +### 5.1 主窗口布局 + +主窗口统一结构: + +1. 顶部工具栏 / 标题栏 +2. 中间主内容区 +3. 底部 Tab 导航 + +这是一个典型的 **桌面应用壳层布局**。 + +适合任何多模块产品。 + +### 5.2 首页 / 主操作台布局 + +首页采用“中心主控 + 操作按钮 + 底部日志”的结构: + +1. 顶部状态胶囊 +2. 中部大视觉中心(引擎球体) +3. 主操作按钮组 +4. 次操作入口(如设置) +5. 下方功能卡片(如标注入口) +6. 底部日志面板 + +这种布局适合: + +- 有“启动 / 停止”主动作的产品 +- 有设备 / 引擎 / Agent 状态的产品 + +### 5.3 列表页布局 + +知识库页、技能页是同一种布局模板: + +1. 顶部筛选条(Segmented) +2. 中间卡片列表 +3. 右下角浮动新增按钮(FAB) + +特点: + +- 不使用复杂表格 +- 每项信息控制在 2~3 层 +- 点击卡片进入详情 +- 新增入口悬浮在底部右侧 + +### 5.4 流程页布局 + +蒸馏页采用: + +1. 顶部说明 + 主行动按钮 +2. 中部进度卡 +3. 底部结果列表 + +这是标准的 **“说明 → 行动 → 进度 → 结果”** 布局。 + +适合: + +- 创建流程 +- 训练流程 +- 导入流程 +- 任务构建流程 + +### 5.5 设置中心布局 + +设置页采用桌面端标准的 **左导航 + 右内容** 布局: + +- 左侧:设置分类 +- 右侧:当前分类详情编辑 + +适用于: + +- 系统设置 +- AI 参数配置 +- 权限开关中心 +- 模型、提示词、功能项管理 + +### 5.6 工作台布局 + +工作台是双栏结构: + +- 左边:实时画面 / 监控画面 / 预览 +- 右边:分析结果 / 建议回复 / 问答区 + +这是典型的 **“实时输入 + 解释输出”** 布局,特别适合 AI 工作台。 + +### 5.7 工具页布局 + +标注页是非常标准的工具布局: + +- 左侧:属性检查器(Inspector) +- 右侧:大画布 / 工作区 + +左侧包括: + +- 当前对象属性 +- 名称、类型、描述编辑 +- 区域列表 +- 操作按钮 + +右侧包括: + +- 截图 / 画布 +- 框选操作 +- 可视化 overlay + +这套方案可以复用到任何: + +- 标注工具 +- 可视化编辑器 +- 区域配置工具 +- 低复杂度设计器 + +--- + +## 6. 组件关系总结 + +下面不是代码依赖,而是视觉和结构依赖关系。 + +### 6.1 壳层组件关系 + +#### 顶栏(Title Bar) + +包含: + +- 页面标题 +- 主题切换按钮 +- 主壳动作按钮 / 或关闭按钮 + +适合作为所有页面的统一头部。 + +#### 底部 Tab + +包含: + +- 图标容器 +- 文案 +- 当前高亮态 + +用于一级功能切换。 + +### 6.2 卡片关系 + +页面的大多数块都应该统一抽象成“卡片”: + +- 信息卡 +- 列表卡 +- 配置卡 +- 进度卡 +- 说明卡 +- 预览卡 + +卡片关系原则: + +- 所有大模块先做卡片 +- 卡片内部再分标题、正文、操作 +- 不要直接把一堆内容裸铺在页面上 + +### 6.3 按钮关系 + +按钮建议分层: + +#### 主按钮 + +- 绿色 +- 全圆角 +- 高权重文字 +- 带轻微绿色阴影 + +用途: + +- 启动 +- 保存 +- 开始 +- 新建主流程 + +#### 次按钮 + +- 白底或 surface 底 +- 细边框 +- 文字较稳重 + +用途: + +- 教程 +- 取消 +- 普通动作 + +#### 危险按钮 + +- 红色 +- 只用于停用 / 删除 / 风险动作 + +#### 悬浮按钮 FAB + +- 绿色圆形浮动 +- 放在右下角 +- 用于新增类动作 + +### 6.4 状态关系 + +建议统一使用胶囊或轻文本状态: + +- success:绿色 +- warning:橙色 +- muted:灰色 + +适用于: + +- 启用 / 未启用 +- 已生效 / 未生效 / 已停用 +- 在线 / 待启动 / 异常 + +### 6.5 表单关系 + +设置和编辑类页面的基础表单元素建议统一为: + +- `label + input` +- `label + textarea` +- `label + switch` +- `section title + desc` + +这样所有设置页会保持一致的阅读节奏。 + +### 6.6 日志关系 + +日志模块不应该长得像普通卡片,而应该独立成“终端视觉”: + +- 深色背景 +- 等宽字体 +- 彩色日志等级 +- 自动滚动 +- 空态提示 + +这样能快速把“系统输出”与“业务内容”分开。 + +--- + +## 7. 常用视觉模块清单 + +如果你要纯 HTML + CSS 实现,建议优先复刻这些视觉模块。 + +### 7.1 顶栏模块 + +元素: + +- 标题 +- 主题切换按钮 +- 行为按钮组 / 关闭按钮 + +### 7.2 底部导航模块 + +元素: + +- 4 个 Tab +- 图标容器 +- 文案 +- 当前态高亮 + +### 7.3 主按钮模块 + +元素: + +- 图标(可选) +- 主要文案 +- 大圆角 +- 绿色底 + +### 7.4 卡片列表模块 + +元素: + +- 标题 +- 描述 / 时间 / 次要信息 +- 状态 / 版本 / 标签 +- 整项可点击 + +### 7.5 分段筛选模块 + +元素: + +- 一组横向选项 +- 当前项主色高亮 +- 未选中项弱化 + +### 7.6 设置面板模块 + +元素: + +- 左导航 +- 右详情 +- 输入框 +- 文本域 +- 开关行 +- 保存按钮 + +### 7.7 工作台模块 + +元素: + +- 实时预览区域 +- 状态角标 +- 信息流 / 聊天记录 +- 建议输出区 +- 输入问答区 + +### 7.8 工具画布模块 + +元素: + +- 左侧属性面板 +- 右侧大画布 +- 区域列表 +- 描述编辑 +- 保存 / 取消 + +--- + +## 8. 页面之间的关系 + +从设计角度,当前页面关系可以概括为: + +### 一级入口层 + +- 微信分身 +- 知识库 +- skill 市场 +- 员工蒸馏 + +这 4 个是底部一级导航。 + +### 二级功能层 + +从一级页进入二级窗口: + +- 设置 +- 工作台 +- 日志 +- 详情 +- 新建页 +- Demo 页 + +所以页面层级关系是: + +> 主壳首页负责聚合入口,二级窗口负责展开具体任务。 + +### 工具专用层 + +- 标注页 +- 标注 Demo 页 + +这一层偏“专业工具界面”,应和普通列表页明显区分。 + +--- + +## 9. 纯 HTML + CSS 复刻建议 + +这里只给视觉实现建议,不涉及框架。 + +### 9.1 先做变量,不要先写页面 + +建议先把这些变量建立起来: + +- 页面背景色 +- 表面层级色 +- 文字层级色 +- 边框色 +- 主品牌色 +- 警告色 +- 错误色 +- 阴影变量 +- 圆角变量 + +这样你后面全部页面都能复用。 + +### 9.2 先做 8 个通用模块 + +先做完这些基础模块,再拼页面: + +1. 顶栏 +2. 底部导航 +3. 卡片 panel +4. 主按钮 / 次按钮 / 危险按钮 +5. 状态胶囊 +6. 标签 tag +7. 分段筛选 +8. 终端日志面板 + +### 9.3 再拼页面,而不是反过来 + +推荐顺序: + +1. 主壳布局 +2. 首页主操作页 +3. 列表页模板 +4. 设置页模板 +5. 工作台模板 +6. 工具页模板 + +### 9.4 不要破坏统一关系 + +如果你用 HTML + CSS 重写,最重要的是保留: + +- 颜色关系 +- 卡片体系 +- 顶栏风格 +- 按钮分级 +- 页面层次 +- 列表页模板一致性 +- 终端区与普通区的明显区分 + +而不是逐字逐句复刻某个 JSX 页面。 + +--- + +## 10. 最应该保留的内容 + +如果只保留设计语言,优先保留这几项: + +1. **微信绿色主品牌色体系** +2. **卡片化布局** +3. **顶部毛玻璃工具栏** +4. **底部 Tab 主壳结构** +5. **列表页:筛选 + 卡片 + FAB** +6. **设置页:左导航 + 右编辑** +7. **工作台:左监控 + 右结果** +8. **工具页:左属性 + 右画布** +9. **深浅主题变量体系** +10. **终端日志模块的独立视觉风格** + +--- + +## 11. 一句话总结 + +这套设计最适合被抽象成一套 **桌面端 AI 工作台视觉规范**: + +- 主色是微信绿 +- 结构是桌面工具壳 +- 内容是卡片化分区 +- 交互是主按钮 + 分段筛选 + 浮动入口 + 日志面板 +- 页面模板清晰分成:首页、列表页、设置页、工作台、工具页、二级详情页 + +如果你接下来要用纯 HTML + CSS 去实现,我建议把这份文档当作 **视觉与结构说明书**,不要把它当作前端工程迁移文档。 \ No newline at end of file diff --git a/overlay.html b/overlay.html new file mode 100644 index 0000000..d9a7ad2 --- /dev/null +++ b/overlay.html @@ -0,0 +1,12 @@ + + + + + + 微信 RPA 标注覆盖层 + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 23f7657..3c2aa1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,10 @@ "@tauri-apps/api": "^2.11.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.20", + "jszip": "^3.10.1", "lucide-react": "^1.17.0", + "mammoth": "^1.12.0", + "pdfjs-dist": "^6.1.200", "postcss": "^8.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -797,6 +800,256 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1977,6 +2230,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2002,6 +2264,15 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -2050,6 +2321,26 @@ "postcss": "^8.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.35", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", @@ -2074,6 +2365,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2199,6 +2496,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2240,12 +2543,27 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.371", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", @@ -2438,6 +2756,18 @@ "node": ">= 0.4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2495,6 +2825,12 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2534,6 +2870,27 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2552,6 +2909,17 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2570,6 +2938,30 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2663,12 +3055,45 @@ "node": ">= 6" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2861,6 +3286,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2989,6 +3420,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3099,6 +3545,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3114,6 +3566,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3123,6 +3581,21 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3284,6 +3757,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3466,6 +3945,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index ed06d91..a8be12d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "@tauri-apps/api": "^2.11.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.20", + "jszip": "^3.10.1", "lucide-react": "^1.17.0", + "mammoth": "^1.12.0", + "pdfjs-dist": "^6.1.200", "postcss": "^8.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/public/media/refund-guide.mp4 b/public/media/refund-guide.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ed38306c742c972565e62b251dcc26117c9747e2 GIT binary patch literal 98484 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⪙KUW2nR;^Z2VOS{xZ=nXTU?~?En_h+3!v5Nq^^b zcAB75DI7r?Qx{M=ShattF-14Z|F^E%6!vk{>=PJ6Io%ya^^Y&X)d}n|h3UmuW4!%z zV8u2k#eUqTp#Ly$oFB1xU?*QpgL3KsnyIuxe+S1?eZ1a!T-WD@OP)dHyDy|3Q(|X0 z@ucxX)G116Y!ijT9y-k$AoqLgd`0cWzW01(ZGC^zCmdUl8Yj6X!*{YLcuE$YmFJ zHb_flXkc^Z-uN_DtzNHo$6v@4$aq2LGuAO#;n}c}2Px7v@%sGc4_#Z4#CvGhhL>z4 zXwIVeE|~u;b2$5jz2vhLSH@wwpqFR~oe$Pm`3m)!WN}ApmBcH*#xY9CO*uqDeE5c< z=S-KrvQTpSej?^E!AbAZZhz6OvXVs9K5euqtIv!)QKGG!i`CfLi}7q=)RQoYA9bh^ zK9Qkkk~}%(p5hx#BEU0dMbQjJ*$uQWN!=j~J?c+O11714?^qVbCaB3Ro*!K|W*}fYlJ42tB+IL3S)pVLo z{$=ivt?}4Z_K_Pf9^R^FobN?6t=`e%yJabG!H0LLprz<~x6%GgEplOhukx)s zl661ND}kNiHTQD-ha-!qc}*yr#gxy!2ENO7EWPuEu3er#6)wlK;hU^;ujQC*JP9OF z-a}3NI{OY6kXHPevS=QjyJFdXkEScv^~N?lK+)gX#vyb&zZGomF7J=$f^=KbzT=?d zS?>NwFo{uBS75s;UreT4r=~t}MF$>cBz-6vDX}OH^$ik&(j&tT_UHt-8M=(9_6A<*HWGGEov;4gu)?zr;nQ&b8{x% zEgWvD&0;qXy~$YV^|TQ5Bp(QZ&oPoIPb40^EKw{CTJ$wQZgE2b*SFNJamxRgnT470 zEbG?H{;gOPn6@-F#m8m#@y#+>8X~?|Rs9Foax{#BB)_aUQU1U6I~RMy9CSSqprZXP29EUcbe=k*Yz31-lN#K2~f-8WfNhX>o~1uU!& zPq==L^#&KM1x}hraD_w5zK%nJSNhgIkGtAuz&*pW3Tea{+WYU=dzcYEJFP*D*<3e% z3k`i5&iLqsaAan2l4S7Y4-!2h#o_d|2MN#6)=)sFr9Wt+DCs2|tOz6fe_ zIUl|8JsD5l(iAksyUQ4%l+73hs3!@1$6GO~6!wot51w|iUyocYH^%!+@Wci!P=47b zASK8VZDW)2s77VJ=NA-{*;@QcU?*mDsx}hm7AR4pWTmRH#|U+AMqEagTjMI#}~l2KgBz^=H(# z{0bz1*RxkO9{*(I`{@%N6L_zRZSl8zTm{>oKKsyHoqV%5XQvV{$3q(WmH2HH$(>N- zP2hHCXOwIq-F3KKY~TOG);k614u9L)Z`-zw)z)hFYTLGL+qP}n-K%ZewryiIPXG7b z=iBGvt4ie}sY>PImyww{#(WZWeQPf1vVVp22~-+K+xeojEWs*60`9;+HS*qzi?rjk zaG8B@m>LP5w(QWu)-Z6>06

MEp1ZkF# z?v$6d9G&KAwYMumOf)#f(p>k{u40+mb0EnwppQQoY{$C{<3xT>f47U%%yd(u0&59- zCC?~eLb|3Rg&qx2V7@XuE+assVJVz?y%ox!)5ZStMfkIMyRfEBxNy5h5XNLjU4W^;V(MCKj=DaQEFZu?J?g+2J_a?p| z-VXwuh)d1d8W|X@20)Y&VEj#SGox~Nh~unF(|pIPxFNh}g9X~;i$0;g`|&c8?o_K4u$|1Lv#ZpmsFgNiH zx1{el&6)c~WTJt^zzV(Wbjasu^%$f2pvR`Y?$2$yWwaTGi&68xq~?mki|kWcOnmt* z;&B=8dRgL)f>~QtemF7kFQp+tGWEIn=CniAo*o@=Ppi**As~WXr3j)*vOfYvD5*^!SmOr4}5oxG^D}kYgFVBQA&CbiS^rM@rw=?rAv2*oi_wF+`SYKC2tA>qfvvcm>?Ub!xPY z)lQC`dA3VQ&$-S=%(eycS1QQZK^uJbP^U&ZF~14cJ}OSdE5R;7?z0#7kz&ZhY;J(x zkhpT8izpuJe(F2u*C(s=9InT(-<{-D)#JQ4oU~VHr*-9zhYJGGMUEU(hz2@+X)zg} zQld2!bY~ktm;Wi=Kmfym(j|<5JN#t3@STNPN7*XUUI4|<1P6!?e{SrLT}AzUA1TuN ziHV4`p?lz{SlVxkAnN`v{PLFO^)c_~7Cdu!dBC6$kh71;rnn%Ss=t_lXTO8AfEq%q z5Cgn-s^O&%V9bd6Alza2U{dbdok&4kK+&rTO?T@Yn!8Fbm+YrXxradK@j_p}uAha( zmTRmj{(WJanA@s0pn{>->p@P)_JR(9sXiDbZVN|&8w0Z8+voJDul z6o#-gCTB^_7)w(^q^yZa%gL&kDokozl27KA`g9YPs9`A9A=BHz#tA%$X5&34DC5ke zgLI2JiUhE9E3&_eq5~_<|H1&RPlC@3%I&zJh*bqe8cdJ5N%`d#ow9h)>y(ah?SRfgC(l*$-dq*S>V z=dtaZ?ka3-ArL67TO@4W);jv-m3?}(rJe_diz(D^SHIUP*g9eEkjSf-F9IK3)bw{5 zTIC52T+FBhUf&0(i9H!faDkRdP@sao;jCHl#=+n;g>F2P=1kMbNL#{p*LB9o zA)C;ivM{-X>J`t+_SUaDQ=M#BCsYbFsfLV*5tGAiqy(>z5+)J;qrX#-*1J9(^4#{h zBhqlE8HvV)auR+G43g6q&WFEoj*y09o9^^N5(=ne(*#D-_w|hzw|3&+F zjy}FaOlL>k&33)=2(>{o2{hzak-o!xY_-Y1x_k-JavG0PV(~DC*aVb@BbQVtJ0o+V zYqlI-?PVxc9$3VM;gMSf_P1S;J1luse)HV3@2^#-D8v)5NcVpHpnmR5xc1*O8agbn zr3+x~LLH!XO1~?NfZFAp>GRqnD|X&9Dw9a7)OE0E+A`ia%&mq3-uI z&e{$WE-Uo}xN=*`G(KxHT|nI6{qr)P%e3GVO5J$T70bnscE3(7XQM6X8Hfm;{aTm0 zX)wV^x7UC5zBrr(CIOS5&P0)jCH3<4i$tq1a}US)-KL6UsC3uz$Q9JWX+T!%GDP(h zJul#9JRiAdh3svpRnYey`&>1{&sq4JkbYm)eVnue86P6m)_`FYlwEQjoTe`B;U7Pm zrRvq}xYD83F82HA;%*5PrT&MbR0fn=<0Srl5Rz+fs5T8sSt%VRk+j~D*7)7KFTAS=3>vIURx9?l*7Ls5 z(BgpCqF$i&IS~o>f7=k6lmt-+DS&H8pU8MsOGzFs%u8PBD(hs zLKSvqjq1hMS|N1(J7Cg~F#VJ1CEA)02f$ud+c^B3IB$3`Bd6_ZiJ@30FCKVaDkxeG zr3^Z_d`l#m-Z#sBp8@<5Iu9!DylND;gs2m?&od`vv1{4r8&}m3++JvCQ{NT70}|ge z^ujgUoB?>D?*!9FR)aXd_HSP1=r0|tWwrJHKqAIsR>*byWfma~C-nJ;^;g#1)|8wn z=GZ?cnw+@b*w?@H*neDp=FZLLZ-P8SqI>`ecGuIuZ~hs?xGhPLCT9ETm<9In6~<)V z#a&Re>MF+Fqefj2S^DW}Tf=ePcVzI8wQIdSET7Br$!eVEi#M;9gT=i5K8a0n7lK)g>`r97=H*+61$ zd}Q{lW$*3}FXBc{C9!bAUXCVVt2?1Fm`obmKaBs{ zpJylc?W1dXgv0?rT~3oOU4y#LY@jR=_?f89u`wIUpqCWAF14xXA%Aa*sDwedBoxx{ zL_uW#nx6Vc$8gGIqcI{P3Us4=0`^t41z$sNP|-qRb;T8vLS@fLq8B`tlC+&Q=>nsz z1aENhs3m26iEh?|Kp!$K#h(rXN7Em4YvWM>Tt^JPpG_NBz`QyFmeF;^v*6rt3UQQh ziq+{DaWPB*Nw@?9SvbEW7h*2<3!g9R$ICMY%B}nH@>D*fcJ5eMX0A&B zkZ6>MpQ8`6iqqDajc*W#^)XuO z8G#CMHxi8y-C4~LYGoD|T*6n@mS9cz+5&M1k)viKZkiu1)U%OZp-NwtE%!-N!|KL& zRzcP|3`+Jiiv`q5JnEWcYmKcv5$3=o3e}%qq7}1Rdt@LgZ69xbOv4(~KJAe%htgmf z7am|4^)*)^<5KD!Ap&%_w_tr$?N%E#@ffS$3>>>+c@oI-{y{9gyR=XnTy=R9>ml^q^`q$3ff*U=LVKQ#vN{Ji@x`#^c6bTFn4c#qWqkUg#+C|8 z!ia+B|vGh#8zjCntaC|tCC_^8ekAAB5t?4MF>H$%# zKzC=p;(Eg#>0LwbboL1ls6OYT7!(9%_8%S_{JKFFHK$K(H75*upYZO4JDdzWx)~A&TLox*p-d1q6PuCCA z70TZMq^?qF9PIw*!Wh*wk@4?T>Pjh}&&=Yv0%74OtEh;3z~w1uYsrEb#=cof&Xel2 zGHl}NGR2D2bayf~h8g@x4CtSUm9ZSIrey%>rpskL zm7AuND1P$`--vZguW{>18)FF;<+-4d95`fZ6Xh|{N)jHOxr1CuT_zY?3b5C3k#U4` z31iTm43~}J)Z4vNCt%|wcYi4z@r=&RRS%R6OSrAbl_<;pJed}_I=Q3TN?cEmsWKCr z-1C8->QxF4YA-vF4 zz?Xql2m&qZnF|fbII&hFMR}}jX>(1?)RdXvF@C(~iQ=qEeK&>9rOO5eMwsOF4E`~h zs6CZaPL@uGHyh|V;Rbe>AFgpVk7yQ>p^>qq6FIi@Eu{AFQ0%@OhvTB)o^)9Y)8cq1NZ%Z=a<&}nem{p zjZtV{zw0Uf<36wOXDU-cfppZ%G&>|B$tkhQiU`VBtyU^1bP6{swAme-@6No*Gs{f;%?E z9AI%YmLyAvM3%Y&-J&#DvUz}6Z(v|6lqVQPwJYZTQOFZyf~VRu0rWT}$w^$_ssa@X(~)@(qw4kJOeoN!^CqZi85fw2 zQU_7b!G7wRz27A{Yi@x8ulfcqf`!=uvjqClbKP^HU8gP@e&`8{8B0;${kJQyDZQ7G z;HCkVFgsRf3_ouaIaMvi>b~zZ*WD6R?0Lb77j8W4Wc$G@ssW_AZr|qh8Aw|FOd42hs;{A;ps7YmL3x_&jT5X1L|tog z^#Np2sL)2%;Cr=x%8O|QwYJ;r>`8J_E)uji8Y!{y{r)W7#B{5&CyZ&CCyQVw;4*NG zUcmKJx*nQv_yc25i6y&0nHwBRu5xC@z!upDcaB~2hsfTzmtB|^1}fM%HagmWqt;?T z(Nf=!l;p8=<34sFn+TnHmtjFA(Bdx|_=ADR~d$6JGt& zhP-mor!y%lDRs;|hgwTb1E1xSW^gg{vaFE0owo3@WVWdaeAOeRi6;)nM3XjRV_@MG zv?l!NR_5?$KK`%$!~gyN(Q0ht$)U52WX*wCQ9-&i1SG!$z}|Bz<%$mvWAGG<`|Vf; zoIYFbWERZ+O{ASl+IeT<{i#nqV|dY~Cgdt~PgDkwWAaX-?iQ4b5C3VXnLznE!0K zw#sU8iLw-uwhp`#Vp2(0Ykt^l9|uMfFBOKOI{Qmf4$FC*UdmhWbcYmfZ|wCe!zAjF@Ym5=J*2 zySTQp^4^C;%&;X=$cY2^!3uKS^%>tfS@^le6w#K$-;n2^4=xXHEZmV{bNFtT(mhPX z7t}WJ@$tS5R^z99>ANofG{ayCcF0HaE`~*AAp2{O75a-_aj!RSWWt^!%YssAh%DYAl@qV|*nAq~NP_N(qIOJv^xTht$G)y!q^Ub1qIf}`GNR`zrKIXUW zkn}zN72eTKCM=;8bvaD4^$qJ69C=bgJsNjOzZ7xp?1HTIV$6EvW_%DOP`&g*V!Ggt zdyN|X(sAmyFV$!xW}Na`K=Y-S)3`zAc{J;G-7l5nuH<2pNJS%LjPvx)XxG~iG%jNxhA*ySO3|T}rwH%ok(Qrgf9d;i<^r8Y$nyfTy7F zqA(c*Q)2;gJ`U$j@y z$K&#jhl9zyi?$JEQsbPS-l5O%#>?s3scGexP*73XTIb_iCBWM~m6DmIId5^{pyQTo z-w*ZFdg5r4qibc@bDJ)r>Nax!nS8uuf-MbQbz-^to<;)?BV|VBzpcRB2rq% zeI4W%(-~U}vDu8|f(D zB%{ymW_L7kzVh&+lDPphrSyk8BQyRlM^H+Wh7s--fXRr4h32%#uLD~e-e7%uUR~_J zf^n-t6tEqbeID@2JP~3OHerR-HnC-+KMG^_SGqAivq>FjQ!tJ;U?ZT6VNY3!Ku=@= zE{APVeZ#zEVe|wEB=$zzKI+fV5vsyd&Zr4T$X{5w$(TOQ=YkGXI>Jd<zJY2s|4n9-ITAyw{s`2ILc0z-0|oOGHr^#ztfiS8D_2%_GqJ7RR7*B*UH?P0 zzs#r13cVyk*q8S?bd2K7BWj$D+fi4$i58-V2O-?@I}~2P;W5@g2bX1I5%z;ymzNkG z@aWH*xaOP=3WW%VNu(#U;S0v4&Ub#+6j4coN*RyZ5-^QkrHgb_(wyyHπ7u zN;-~BdmryVr<;ErU+~kFS?RQaO3NE>;6u^Q;Ah9{$%(whp@_Kw-4s}#^LARPN#KQd zinx~@z!(QZFb`f^pcB7KKr6uys1G!givR-|UTg`jmng#LHV0Svp(2s|rc|Zqdi;V^ z!;@fBhq1r#Usj@(4M%~raZ+jog=s`+a>)K;lLBO$SML~c7#)VR!|;;l}|E6sNd64bDNM{mOH5anMw zC5^*QEN~X6WOw-;$DY=R_lv@e)1GJhyXsgP*{J&z zD|5~sa{%DQKVq=NpTYXyV(>qlnEK+SOau3SO|i&Qrs0#Ybq$rSTp91KTD0=H&H0b% zUb)<*)5}y`uyOC5XSBb5F%PR#<9*XfN1O;9qIrrd2}nNj0mVFiq)f&gKJ<{5pi-*0 z!IMPY$o@=u4K}o>*+ zYOBY&ss_kFnjCb%qQUutQ@yp=ee%E=hQ_;r zN7?JsOf>+e(<@@U_#%l{|8DfV6XquF`@>u*wx>fBT=ixwr_8Ltw`1~XPf%8nPF z&je+aVq-iud|_ zDMo`uyEop}u1-)mo|$=X_#CWVZTJ)uL!1C1KaG2vIj!DzN9N)}k?u^QVeIV355O=L z`6-KWLd*t(@rX=SXji|^iqmWsUsF?Nle(RQSfYhg1PC#{_Zs!2AQDd4qBRH1LJvL{_)C+U#&rT zA#OJwT)RXgbyU@koBT@%9FRpyvqo65c|&MLj}lbDjxBdNSbh7f@A=$Sjgl5}b2CI! zeS=FDRl6kMCnaBtJ8GrlWTnAnZ#tnMIdBJH5v*iZ$p7GQ;GK@@=Sp?T*W7#Tv4n)` z869=os6cX8dLErE>>uACHHJwUu@wm4ko}NUI zndraT=!GVI`O6(c*;(Hz5c>-8<&0wTA4%0}dK%Z;HhBf3yvc}=HvS_J8T(NHHNQa0 zVejBUjMM{^VGwk$Am`raLLh#n?R!*MM+4Q(72(8h{N|Z74&5F^7Y1M4FQpmf>W5LS zgapsl(E=T+ABKsG?NM>Qqq%oT4Lfi4N9r13VnChM5UIiH$;$woWNZ7QwsCkev3Chj z0%97j(>bk;X%%iEUS{QPW1=BQ(cT)elGK--`|^lx)uw-m5KO^;HZQOThvmoefpJ^yjFUxSTE;KUOX{#!R# zzD-i_a`cne*i{zUhDRtZ0+G5UcIROaDkCvk%=RUomzKXQz#&p1Pg{~?-ZnSh8%sCY zM516Yp!N(AXnLe77qWcM9g%_4Gf($wyv*(CM7Vu$d9KD%`)OakTc3RDQ1KQGDl-+hBVld1M+Pn5u{c$|z8dTYDKaMO+L z1M8IH$8hyFOW>?NcXG-|*(!)-+@;MLPfeTTMbMY(?egVlYJRuhzD>UKIVNpt?@gkj$4XyPVzJqA)bJYS z7pL+4Dxa0)KfJ~G4P-x~!U&fdNLI);b%PD*v#loEZHEUjZakm(IhPLe^w?c_Ij@?; zKtAsZfk$$N!(7l^GC#P|vU>8EtyGS8GzT?`YNEuBISa8dlr+R&@4Nj*aAB*xKah40 zl9qka1blE_f-;RbRAvq1PoerWz2g0Ca$XKdL6|WAaMB3^8$PJ)jnlu$g(TFsD#21G za3yDd!Xyo;SD*HZgU+5wi_JV)xdZ1i_8JOQBVV3P?*3gQ`9fAgu0WOmccM4$WC)x^ zI3UqKIQv|2Q0hzGT&+2zJbWWgj^K)o1Trj$D%`o!)HluyUAO3L$dnlIb`&rLs>Jui z4b6*MeGzUW>q#jD;kAIcNpZ?i9*gM>@~2B-Q3IqZ)b{#4DHSbFNO&Um9c(vw8>0uH zpjFXvy-D5u8L6v_NXQ1bKl_10V4C1SF&r@tVIZ4ZQ7GA$*xQYwPK6Mk^l{9nV6pk7 z3R!owm|D*UIvx}Ku~=@klFJpk%UY2VgR}gvPUs*EPZYJ3-rZzu0gQ>Qgu#n$%7oX=wI z4ZL-iqG;agrCLUIf}{K3YaH_OAR*hH7P`TC4#*0C@E#J7$eGf1X$t}==T6A=aC2aM zd4u!;Mu66W<uQI39#q}qZtTcDeTlx%Y0H%iDY4SUCwpXlRW z9xvBeiFU=va@HRKkL!Xp!RtdD*naT4=7dgiVLn~S8_sMcmpnBtYcz{G%9yC!phc>< zg>Luo4kyMeeqGZJ^mDx+^3U$|T$2HXeu^ngC`myqjO%x2)8BMsxKTSqi*y(uqE0uO zN|x_Vq6@om67%f1djV~|WS%2!YWW^nDrJI0CHYvdP@W1ZB&*(bE3chA>KRv$s8@o> zay6D2Z=Cq@BXZH2G80|WLjEaBgYa##BD9Lu;!)P7J`ozWLrcj$FNUeK-{E!C;^Jk7 zzLOi-QXfXW#W&RR|4}dz{tWm3Qn3GmRehCqU)iadiu0-8>_Fk^isfddyDi{(4xen` z{{|EvDYJkk-<=4TFELF*if3aVcR|mMR>35z|Ma6~%$P9R#;Y*Hz5%;zdaS}}kC%A+ z{I`7nY3%+e4;=mYTlkiEceL!@V-pbx+>XZ|@={-PuzC!^`R@q)irc8$j#}M%oFALS z=oJ8fSNs!*%t1ok%6j}M9%1}J;+yFyt#~n%5(Z8S6o3Xl+ z8YzwrOsj3rM}vIKWg1L3%KjUx`U(Zn?Q6*(pA$z?0tz7|sR=rBWt)qpIz$s>CfQzX8x-XWC@mC_Shk$NzFfywr6j^DNIisxQ)v!DNG`t6a ztc4X-?j%N%xVRj#C#?b0NLW%&l)89pzLQQ&;gPmZUYc+ai=kAMk%=G6<@4ApQ(csL zuy*`~b7FA}uTVgF`RcH=3yF9eUY$T~O(xSZaoWR}PRw-wmP_SII;nY8vN6zRrgE$s z7N3-iL22Z`*y--zoB@&TfIe#qWq7)i&ELNQ_^*i+PVn%?@p>Un*;P)hmP{Kde@)HrP#C_Y#tYW3T0n|e zg6rA}Z$d&=EggGPplh?5WoR#~$5t-DZ#^92v%iBTeK(r{s190YDCVgy2Fj7)4S2hZ zBigR0eOxyXtZNu^=|7vrj3@?2xfO)V`bhuuHUkSF;TGo9h$Ny_Yg{o+BW)6T)31V1 z@f0hvh{a5swW!tDKR2uiDIP{LLh7k667Q*;OS?`|?8vLjg~w^P5}AzYkvfW?tT9$w zD6C{auV}hM&R?A84`T>)edpM6> zluDw7E0tIO!n~9-?fZGg@qlvDe%yK_(T3UI?0`+cwTebs!((JU^ZTmBZXA@j2vt3; zot`3YO$j#~8HSAh?y6#qS|_^!_v(K0mzwdbmZ$zRj&I1mJn|`ZkL#9U!sj#6lR&O; zkZf}P&Bp#^0+7nS1Lm_qp|1lulDp1VBT@Qa#jUyb2-KG>3V0Ufyk^v}r`Kra_%_d9 zZ5o}y5SSedE+L66pM;CVR!;_6H~pagQ`NrV=%tst=`mY#aphDk&N#@C4PF=G->;0)xCl;)A+&5$=*pfV1*9%F{a2+{BX5KV zSCY7031=CK9A$nWeCrFQYz{P?M6kz`JkBpwDp_gk5il9+AoF*kM+iDS9xb0KoR%B; zBpm2_%?56Y6oThG!Dz;4w+s9F3|Y0^*IL$6k0M1C{BjcJ*7xkl;>FUnsZ&MA&sa5v zZ_AtIlu`GdPF_q{rZ5JZE4zb7dEh+StH|j~;6ZO~%;Bhr5SRV+J~q*IXpyB@dsmlw zj;ORg>DppL7T>^vtY3liSR1jp6!o}`Ex`r!DelLMo%hl`=Ip-B@PE%Ma=xMI(rHIF z$$pJkz4lTyf$~cficUf^SoOT zN&ypfxh}@gb{vA>>WH<>(i18V=EWzk*1Jr_fV?(#Lh89L#JF~{#)}grjI{p>0O}B1 z?@eVdZd5x2b5Hm%T8|6gO!*s;76Jo?b`^GW+j9#$~D5eKvu{weJpodzqJx%?7 z=f9aFD7cm`ng-P7SfxmqmRdGH*}Rn9Gx0V4_q+B#dkB4Mmb0X`KjVC!X3;gU4;{&| zeiDp|nzgQ-|2YjxtG4OhA~$K^oO4LhY{hTQ1=D`wrW`)|>IVQOzU{DKL7o0&_K3V| zI$_|QYT>AJ`QVRffe{6pK;`JsygMEseDe<>UXt*$dkSw3{N>iVN2V}LLH1-{hfXW9 zvhK!7!6TKCB%paIX%_j^qi%j>&|Y%!3buy!KD|_#P+=%s(lMH?6=A#zVyPpKG4uaPGH1cuBNr-5$3*;96xnq7z}s(3o814 zCatZ-{x@TK>R3gDW1*Xx9ssx(W@N-O@w`8QJF3edu{V(Ic3bx$X-?rc)ID^%GAR;E z%=G|CeI5YVn?Mf@D6ERcdZQo@IU6*-46T{hIbhN!xSbpkfF4F&th{&RGo7&%o10&F zAhNeAcEQFD;`pr9r%niUp&EZ9BxwA;bGlw(?GVEx9*7`R0yw&_>=-))#-gKPZQSk% z$3N9gI*XMk6cR|HC+082I~)Y~j*%idn}Mm#8YK~mHh|@@2A}5!WAn^sa{Bq9D2daF zV3uFjA!O*{HW&@>w!Z^FlDLTkk(}-oLP3@w`^B#8+ zog}JO_ZQOiA!Quk&aDTkY1k+WT7C|#nt&{|^DCw#s?1P4fboP{{=32@e#ND;TH^46 zryjwc;#gR5TSp>aiYvmj&LnC8o7)C`%zq5M0XXN6Wor$1hhMvq8g05sqE{V@r!cuK zo(VOxqgopgS;-oY{6>pkWJC4?QRUXXo_i~EXL&n1W*Xy?@d zW=aqd|8@k9(*H?DhHUZYHpqlEEJEzRNs*?IVJYhj3Gdw8-avVJY|kZiS89A-F)DJR z9!r8s(6Y)xJYCYG7wpsaNb|U4X?me3)A1s0a>!`6fC7?SlGwMl2s{+tDW!-JC37JI zj=J70mZM_!=me)fr(5p|&*{rg_odi{R=x;2CzJ-TBiKxv2f(!3`06G-o65kh%VUQf zacYrv*1`6Q%~!{M!oH_HJ95PHw6#-JDS8F{i);&cb+Cw-w_3WQW47IUoS4)?TpGLl zvsA5`D@GA6I%^$HjHHMsOSY`FucU~XvB@(}I?DD(#wRCAy)GKv#rfBO)r4!ruc#{R zvOY~+@?(hz4Rl7T$r+x~#>=SKt{^%K;N*6@#V+vgK~`@m-k}}|JAKa%&j!_NofJkj z`p-4e&eH&8wH_9T??}I-#-`qkG7fY?iv=4>qM(p$rgvAtPGTo-#Ngi?1S;hpa1&fiTN zZE^Cv;OlhEcd{hpfHN*NH>kjlm!bgV!CK+*gNFBJmF|lTtLapL${`k-*oOV;*Dg+G z%^=yq=f6A0ATsvGPq@TIkcbNllw_xF0(kg+aQF%oj0zzAAUR=*1ujrU{df|@c(Thv zDc^qYJkUrJpu}eZQ*4L%uq=t9SCM7BBF>tQ&J3D)%cq_!k8h86w*Ce}8H_P}87!F% z?M83-VsL~CMk{F|5Lei$6Aisa>u_K}>MDGrszrjU3k`Q`@sL^KO+yW}pD<;b)REyx zd{Gj{&d!YF-b1;9YitM>vE}I~204_@8TzsG&Jg(?_k$-8un#okVrFdG+~VK!Q>*Tu zr;Ak3eh*w4&@$!9gksfOEq9NTv?`uo=UVeY4$iwj&2o2p-F0UvpDad$qqW%LPa+SC z8-(|oZyXW@VISz27rFZFbJ75b-tO^crR=nev`E;DE!z%fqA$Z29HoV330@N~zaue8 z{3cM5k=Q*bPQARS$baSlQ)1{msg4EmJ+VZlXSJo+tO6seAUJBU`A1ECvgW}U+h+BF zxC7GL0}EoXJNogjNM>Pp?RSEJVI?tJTjUKG$Kk}g-SYu>rnpXd9?j1}Qvu4^_`iG= zIw0HEOZ`_XHS{{vhaB-c>3&BzBS_`TfRe2JAcALT z4y*g3&|15kDcQ_QL1S@tlax-rae^)E$8i6Dk6N*DD1Z8Z8ov`oo8g(TH`Za%E}5#? zcwd^`+~vpeb6BO}zis=fQ}W7)-;R->n6%E&qivfMeHzga!jlill3F+yzA$z8hXXMp zeR(Zstr9jGrIm6aQnTKr#zkh z_){j|>-Os7I%ZzKuu!BzziY5kog$n+?o4TBeo(16@GRA+l{ntZSR3Y@)Qclks{-$| z&Z{wcSQ~;|Z@-f0^_%Hc{G!o;V|1oM;xkKOlUxD=C^PlELte!I-;3^Vr?vvb1CA;_K!L6)e0#)x}@q$p^; z%=$0O;vP$JoAW9rjcck&d{dcUvF_DnxSNwC>!fruwj~-jtg+|ISl0F)8d4maXk1cj zP|4HrOd79EBB$eZ8Us?m(+mM$H029}W;_qXF+;GD`-Q{H{wy(5*fW_ixYSza3s2(6 z!k5rfcFx2~S1P9R<&skVPk9v0pRx7-a#f&B|2weuAFfKIJFRn3@)i}0eQv@}0RvGYi zXg=Xs^dhMWzub7bZ?jVOX_&}1&TQwxBd-c!PFjb_3~J>@C+IOOoQRy!u3BIA$` zFSQjN=%Nrk|FTwgBG$}n0m4z#D{5ef_rbatj3LR*-@73{B-hLz!gp6G_i5VL+wv}h zNdd2Ic5Lytt=O`|D53A(f0YK^&4f-OBQp$Ui3{=@Bv&|6UWhwY2KLYnyJ)`_2 zilzq0817$aew58+S}t<8rVaYMZg_N7aM0vmRJ$0T*|TGJ?}I4#^{2^hQn?wkNna87 z1MCrGz3iHYkH)f_SL|@HKm#8~FZ!hWWXW3Hu@1EfgK#AR7RUju^)|@2?vgx1mA{)J zmxC_?nL%(-R)PBqx|@9D9pE3QE8uR&J%~{SifGly4RP^YGwxoAOwV{KriL`O9Gfl} zAO0eektbTa%s{ohrSs6pbYhYAOOBMR?7+!XaRR z_-4O+jl{0e#QSvika3ty0PBc)&dL22BFQ^Dv1&oQY)|+j-nEROGKUmGbPlP-X}N36 zT!>Xt_vkhZhps(I7D2a1KqyI~+{=0brDP6Du9`(zf{d-mRp!R1Z<{&UbBtXqhHDQ8 zJ%r>rDjCCGzox4n9QQY5RaL zl+wQd)eEgXUg0nzQY#`jslmwVA`RjoWr)(vkLVBMD{;X_^@xteDXhi3OL!~dMEsrZ z?`@9DHYH2zqCCPp`21&b)3>V0@5D0U2=A_R!bh9zmzOH#Ne!HUt&_yfz1W4p;YN)sk11FNiC{|@V%C~0Aqz5jip&B& zunJ4BR`*%~@oi+LfE0rz`-&(y6%YtZh?A`LC`&B1KTQTh-b?%;(sPGYlaI7oV^X}P z%X+3rZN7RFD<{cOwyyK>V(--^17bC>k1>2i)mWM9Ln`V4JZo8(ZbLMc$!}(BdSu(0 zK*Q}q#3a~jX2K}hP$pTlADM#2ZM(4RMaq4W%s(o#$e;1`|Ke8wL|Z6)>KrWQMLF zpHuf>;G`LmW7o_|zZAKmL@zd!VADuO+JC$nK=&-YDFeTpM^Bm{y@OsHEaSJVVv)&7 zWga293vIu%96+w(ofnd@O}S30D z`P29f!K)fzb~W8botO!>^%TK51QlaedFJSXoGg>TS%dcoi-sD-cl7mx+5FYM=mNsc z596FMxJkz#0-JNW`;Y10XQTuO9Lsq48XBoadsNmBPzKwIo!a!>@}K5CRgf=gM&=mt zEXr%6(BRd``mk4>xWuJ|#9zqLHYp~yQoDZ?9;3G9&PM9X7jojoK2haM9=H6GHi#6SWX^ zdkNbNNZZAR5D2wyYozSAvgt{#2w>odcuJIZASN7$VBw9w)$O3Qu?p*6qg$IR!>abs zPmAiDZdFlUmR~%K2}ir)2Ymf8+XIJ^akZyMzjMez#so4_0&hL~eQ7bnPRwU+(KR0m; zu)!aRD3A-$ywULstiJTTaZolMWiJUDiq=AWs!a&voqcj;`jP2+vS-iQoGat(0rIK| zJQhzpGb@<+un93;mKp{2goon=7%jIOT-Y-jBDB>#^RiZuT7@g7vaPkj#4jyKiG;U+ zeyGioAFhFnbJd+-BS?xZhl3oFNAe5s=~r!Emk?*XtoW%}nmNR66B7qU zA1pGkZ^_+Uzo^IrdlB8Q$*J~;Bc#9;g1$^^M;G%b?^o2m`A|%)QbKes|3jW!m@4=V ze~Qo^ZvkDH6@u4S)BbG#-V3KPvk~@XRgc_5m^wVhmw$V-2WiZ*IeGoo z8KCZiJi?AA9(^9+zl!ix<76OQ)M_$}dQhwu)Bi&I6E#tL13kqn6MDQQNd*=RV)fmT z+;tuH?Cby$A^*cuN*m>;Y02T519MfEk{>h`NcZaY9)$ACdccP}vnvR5o(eTeUeBZ^aS!>_zeYvaFQ&ms> zYR|d6#2SQdvWSHV%^qICR!=FGAS4P@#Vu1+eJeU-SmqtMAC`Mb$`4===s_(pa6)=+ zT;3;^cMZf|3H#U9!pKJJjSv!41*|%XcEed%1mi*UrQM%FlaOqg_zI!|SS$^UtlpLx>${ zc8Jvjw`S!DTA`^V@pWZ3byS)I*AkxM6=099bTnV#pnu!8kLp;ig)XD@E#cF6jFGxAO6*5#n?XNuZVn z^dk1F{F`hTOgLkpwn2HYAhcLD)F7V@e zLhh;XkQ6yoW+40i*XSwzMHWB>Qhm{*%^51lM-&g#GTWFKr2EOR>x_AY%bWGrxK@=Z zN59_V6d_(2NdF;S&==m&X=28S=-ID5!j-uY1_(cl1X;f5l-XAvcbw|Lw_Qy41}X8o zqJ~jmMYB;Ino?ZXdJbV6QEhZZzCtT(v-6E>Q`zrFn+?FHoKv$eA519yeng@j*hN)9 zDXtfX#&Y{6uKKl;Tw1TeV{F=p(X^6)()(w>28HKCM}oEXVjv0_2kBQD5-&i0AEOb7 zc^80S0A%6=Y^5hSn?_oH6H1s3j>Uy)NLq|p&uEN@o*)kHdCnIUz2pyz-)%+Aomz+U zH*5Q5x)t?R@wK#UFMzc0*U0a$8-SUnE?)s8hj`ndPY$`_Z5mmi zlU?Gj!`DbEp9Y@s2|6*^ZrZH+JkZ6f4gQ#Q&OQGl{HD-v-=c+xxp!2I4T=haEGj~2 zD+hEp*k!)O>UERBgxf1N6Nztw_2;(_JUvvxAZZm)c?gmCY110OhbITQ|JoKkE62IEvM}QQihnwve7PUOsBNo2cJZYN#Qe+Y<9b3cedQlf9U|^G4 z+h^nz)RET_qd#zPx?XV{#1I&eCb60(+K~6vQ{Km0Z3Z7wm+`QTE7GkOJ-Qts2}y? zp1i(X$}FC1dBlr-lV?KFD80G9$p8St zWt;afr_pN*sOHvsk03sB?GUo;+1|#YGoZ|$Cgoz}A;`|~agKi-qC9~fTAVrpGJgxs z4}4`9Ii!VH&DdX9puIP+1kGwCy8`*hafu$(Q28w}in~Xz9J+(8PdnubwBXuOiYC{@M+(LH>5c-QnB*?U2 zzbgqM(SFvYgH7kM_y+#-LKzbbQK|m8X8Jeag*}_F#lb}1WPj>OBe~-mUgD9!oVpX= zH#$JXSVfLRzG3lL6zP^F__YZ7ATlJhBe9uLdyio9@BK9|aRT+v-BCPEKY#mw<>9Q< z!Z!}`P5iel+qG#q!1;miUsRNG99$B~WiHmGlMi<|DcT4v{_UG@OFC(s25~GY?1fyn zJ+PkRAW<1|r+j@W7*)WFK1mFRa7eAvjc}X`C+9PF;CaHIZY9LljEWA#RE$tV(M3j1 zhYXAL?wXLd!X9G?xS*!Xq>U22_(c~PB2cFm{fpUCY9dqn2<-e?bt|?76j8v!?DF-m z3Q+xi59T%eiy8=Hk2kBcaUGV_9nytq<`l3Q!PH2oS4_rEDI~sftRa!z9Fid0P1CV) z9O9W$CH|3}HVfuLds4-7R)hn^X+Zm}(OrCG2Yvc-Ewe}b(=6uW15Yj6ybg!ZYt6ha zNYxMs{r%OOKe3x95TCOJl+Ce_U=u-)%0&0juoKzV>c3vzq8YFqhBMcG7{V&cAU4m> zxf((Ou>Y(kun2J(Ztvss<_jm+4hEOBH4$OQi&BRwyoe&+I#WWML~(R2rL(0TP zwDyb$PDaANx!*Z1fLLZWBNTM!hK}wNNfe1>eF;L}c-QgL-$q_lbc`1L&hxovtKoPO zCqO*^!v9f3TTtoD`DiadZz+X8MtpE8)7fX`%}e}YaX`TN8QR@?ecVER#9NO8eJh?z zy^0&qd}N9{If;`3>^(HI8Bmz+?O@JI{mv>>IXy{E9pE<-C9O^e;ESWiLdM5K3W*C` zXcW~jJSX6dO@{L$gbg-kra2QVQ1HPO54}ag#Y03<}t7}X@Fjjg4 z=`}0*Erc~82*?PC8{YozpSY7Sdd<& z2#($p?UiP3;syW^pi-tH+8H*IF;aUm5WClY79YV%dZ9Rj*Z}}yd>(O5R))R(fe>7v z8whnoAkh|o=6G~JnSh8do9}-ESVTuJU)F$T%ylD}JW89nY%IaQ zxl;9+lo-ljO@UgFB5Lxf;Sl*KmwsIF*cHKv>EWfme5zI_c>T-d%-uZnYkM3P`lJgo zP4VK4o6<>dG+@^FCPQ&U{|L=(h>|75W;17086V^FvMz&kRy08#cg zQRA~y7$WVJ_1DmIdpwz2^IIyPJCmzUs0n11aK%KkGN)RdzRVwHKepN{zW9u=dM^;| zp5)1&=pdKKFQvn0M{fbg?%ukx^csJOe7I1QFDTJSZwhH$)>mD-&zRkzYO20xf8g zX~)fk*-xLRZ5lASY=|llz%4gPJ;*DYqnM%JNqIcspj_5$XXFPV`CHr+WF$A_Z>4W5 zoT?n7?+BY1lQQ%^{rQ=dqH^TK8GzGp!OzQtEE-rKwM+ANfb9^vQ{OsZpD#p#oM07e zfV5xZ*w5Iq{{A)}z@K=A(Xz8taCA7azNxn|T=ASMvOo9dv3!{3%n*LCD4`Xj3`lgk z5$O{W1j6f}p;7HiT+*>eZ*vn!bO}6xljo4Mf|>vFx@m1vuV9S254!&r4j`GoY7i^Rb3#(*=CQa{{@AZHL~3E6DCnW*}DHlQK`0ozDWm? zp!T#>kR8V|C8dhXUW13TUtzixkD=vMxD-%C!@SPO{a$lWST7XV1l0%g{nBcVNWQ^YZ@t6roJ^rT~gp<8u1C8)!>RgsY!7dIU(l?lKZ@2Q(SJCq_te2^ddWr9F= zZ#O<+Yyv9=8tS8@`^#CoRXD-(q1{pu9(!gzs;OA`c*kIE&ePF79}?nSq>>!Y)$F$X6`&6T=bF>wwtqfIr$z>O?qFxlU`j^I4Zf*n%w zi_mXB)wV8$V-c) zrP3wYrl;3!em`{J$OFXto>9?V?1@xQ%7@~#u! zMoID8mLb9}Gd;pw*ajdXC-dr>ZKV`W19{TPcMTAuFC+-Cm9i_y7!c_qPUwt@e;M2U z{pRoe!pjh*WDlW7qL$P|Ht)#Ht5W;c^(q}ao#%;x9H=!7{^5 z&-)sW_I#7u%>Y2J+5~+ZPr@L`@-l6`ZI;~{lt83Qa#roN%V1tY8sz;_%)5E)0|1;5 zR4w}`YaJQYM877>XM_^5PoH~37r!51QdzE%ASy}ggDQMPaWKIB;S6BMff)lCnvtt; z;|zUn_MhdAtsBy+c(RuMX1n{@o{vLBfiAU$PW>V7D3m0A<^hrihFobTT+f7~m#4ph zrR?^#EJeH*EkSn;_*cHrJm)WcH7*oU=%e1KGmWhzV3mLqqJw zTDxtc?w7gWHLI_CXn4s3rTRAOIM7B}_=8ot;ETATz-=1Bvba=>zg=oRFaI79K1N%b z1KIT6M2~A1-75gijS*`@DO-YOm_i=;U_2S}^hVthjt9JkRnM%W7-wGCXCrf75N+fyJ4%jY%9o4|SR zDot*)iI7B~8c5upvVMzfHB|>^K^)!zD7A?D`N`KUEIxya?Zjn71TZ|WRSHb!^?2m> zbU{Vmn9tFkGuDgKlG8(=pYCh&r(>Wx*Cmki@e_?fAvo)avdhcNbQ^7h%*- zi{Swuakj!veBEAeYXN3bra9!)OqGH@oOKG%;D9l?57fqDl+g10Z#cz%_0n;!rz@mT z7!f*ofsUzPOyxBX+bIxr%IrHcV5-h5p4UF6O*<=Rf4E1U*sTVICk6zD&_Egdy7Du% zG5Gc#cfcWpjo-WQ(cizBmvD+4W9mjtNdDbQ$`QDY5N`XbvBA=Lekm2z4PMg- z(CATQl-#w)s;Yk+mPq1#eCLsIV#|g{e1&oUrflJ3TOAubqhFP8`FB7>Jn^huaky}= zuYS=_QMvnoW1po1vau?>Q|p8@fWzwuUAt)0r${4JCYD&sm-CaffAOUo{(lB+ZIz`@ z_s{7jd(G_17eiN~X9YcgKRrhKosC;d5`(p9b#$jLPd}J~Ens2A|Fpva0Hp7CK*Ogs z%IFi8Kid6__M;)^@oiYhu}c!!rT$i6^wqk2Nv&q0gL8))ojm;5b;TgBqP(Ys(?3#1 zj>=lzG4DQ?w3FIr6;RijEThr0G}1Bk#7=EwGcZN8r%9zPmNA=5AKk~Ow(iy%T83p` z287)%DqKohpW#-zpUit3a-D9xTToX7{#z<-wFFMGMqtlZ)18K1$zH_C^e{5JMzSS@j>sHMZEXL5yd!fbpI}mU{gR+! zYsAKGau$vsn#Bg6W@$<~9DUsffC9ziXmwkO_z#vK+{WG@6?xFyf9) z4AJ&L)wvmQ;gt0{8|7w%E|6tyB^S9v(7IkBEELLvw1)LNBivMvI+(Jx{NoSlIY&EW zDBO?`wda2Y(){Yn{PG6DGe^QuIuMS|JLF`F&-U3y4+h+};D|C2mXl1%sG_#Ys!yHqQ zOGh!bIr?^Z<9=6tF>TXSoo?H}9GkjtbVAxrqv3tww69}8B4R0ee;CB;P+@NM>6I-m zvk6f-!V>=_lwF^-r;ru;o5{jz*&ulHk)x=slha#w=l=QgNAL5c2mPM`k1*+z8Y`D! zw(Asv6VtS(hrk5n`YjM+KLEjbSThHUWR1O3SH|1Co7a-C5v~2{k0ks#YFEGgeE%l*EWIAU?+sj7SC%+@f`&C+r zSL;}+UXpS@z)!B_>J?@CI)PKYD4bItJKIi^{At>RmXY=L*n-k(sDnYjyz6Q{B9T{C zJGx$Xlr0c;=<=(-!B`hFdhh{y5{l5nb^$>%A&jb|FFH*iZCN zpTi#;=uq#hWq)dUFnM-f@}pYG6@;I!Agz{?<@!URs*IBCibnlr>?tcQ+0$bNt4E!$ zJR7RPnx{JeuaRO^OD~E?dUagpr7!NER4`E?Vda$-;!sr9EIW4Kk9jYlnJu{CId90e zNeSTIpL#q zWZB$?8>Y*tIW&Tt|Ei&$A+V*br!mLWEa+Wv4i$3Yyf)PDBY66OmxdIJgLEswR$l1j z`J&b^n+Dzmj3Wbb$pwsF+GoeAJyhb&v}nx(?sNdK?%0OTCn0m6abI`8_vF$-La6@j z)a&yvA;a`qYVl_N_>B<%Iiaun-Cen2M~c;^)e1>^E%w0qRf9lGOmMj>{UqGbz`dRHyj-x8Ii$qnRY-_t~Ob)VC)ub@0na`twMOAm{a zwV$Xs@Lmhwz7+(%yF=0zjR}n;{W=Lh6C}nZJl*k)D0{;1#5&lMklDNYKf z{#E$S1ACl>YVtd9k zKatf{Tfe6B`440!czjbL=nBH7^XcE8KJSe8S5d|G?f$-5g5OWO@sKb5=N|k2u=4+Z zuoN;uMm03EY_`TFUtmRI_FYRm5?Ci)KI8Vp!f5fe#b3W*_aEno?sroa*%*czJmQWx z#UXj#+KDE_993W(OQe*kPRdtrGihknO;=UfD{cD(+w$?J}@0!oL6UDXV5&wtO3p%YziWOW)WqS=^46 zWA6On#+I@Pn0gRx1o?i){;Q57-JCYkXiL)is&c$oTuv5}0g4!_{R_q6EGzQ@(g8w` zcrj->LKH0LKy-Rk2(oLTr>lK7-YV}-Nvu49eLWy#(8)eSa}yg7$p0EU-o>y)7x(8C z=@OX;J+Zy zOD2xKG=519-$(U);(6~RCAVCuM@TO+m-Xyo?wFRy^1Bm*{W&-EDPcAHpr#d_F3=Bw zz9S)A4>^~pnXgSac(&A1I0Di#Ab0=!4Fj!bWv_wdY@8fiG80dABDTu@tQ(@}E4WWj z9$cy~veJA~?&La7HG`>ex7D0prYF(2!LSi{q3J}WA!G$x$d$wWha>R9nep%(M$YA+ z72Yh+WtrG_*!)Q%vx}R5ZV?@J@^Bn3I{<<9tmid(!S+p4z}WTYvZ-~!w9ASV3YbCz z={eUZC(pupSysyNot;ip860g@geKN*T2}>8Nt_+IHZZu;4@tj zTh6%QA;SGty+-=%PaDbNGWU<6mE$#la~Dx2{(@`8G^H8UK6(wS)7h@zoJ%PS7FPtr zwKaHf^(spU5V~x6gs~I1zxt2`gt5=nk59}DWcZf&V5p zY^+k;1lCd`ig=S-8gSQKo6geajnI5%1o$Ixplvh7Ce%u(lcQT|i_2Il#8V^S@$$Il z9M0^P%ZtKP>e*rQe1q-(!fbOT`aR_mlbe4@>6IqCXVavS_WRQ+qF6`DT1*7A_H!!% zi--k&1yF9CBlqS(?oH2YisI!Xt)5koi=@j#*EjK+pZ}6Y$*YT&yIsl4P2~hZin-rR zyV--qvN&=Ksjs2nixnG${C6tJ@uVrgAg6O-C1Ye>p0*m2qVJ~TH0S%<^#1G}I3w@W^`S$@z4!d1bQW|9u_3lKn;bg1C3SCNnXP1z(IZ^?lS<38&1$RJ!iN%P*r5`ac zKm8daH0zowd`g$|9}TC|{gWJkQQ^eGk^YmK!P-%+@X%l8QA}uL|I;mYG;0J>B^ZJ! z4z@UTyZo)!=Ci-rL1WM-QV!u@_s?^&t}~4WFB>K#)7xq!-7CD-tKx0^k+D_txJhaPfo!4 zVl_eYV$`}K&@JYl$EeOjX)xgbj<0lPW<3CYAlhs#)mQ645t>7+&5w(J)>yu%+A*Ow zMa#w=R5lb~t9HJ1Ci?o{*?uWB`uduZ*6j7tneQ7OG{?6>8vC4lYn>%EXAlJl>5MhX zeWskT(RFQ%2$@Vp>?hQQ^XyqTxvRe44!a6{>T z1HfxdbYIsErD**yhg%y8Y@j-}{Lvu)Idw4dxYN7MP!YY_)s9x!w*V0>=KC{Qi8v(?~pLjmkAMvOez z5+>`Fmg9jv28{HG7OR5M))8Da^y7J7^B10yIHJrIbH+obhVATDmCe`>-h3aTs^4rm znf6gqWht~0DNuc6XoCAq4KKN=bE zZ5zOLp{V9?y2Z@izJP71%7)k@NI7p+sP^h0p`g>gV=oe1G&?(T^>>8|3YdkgnkIPU zKAky>u&Ve`!w8%1SOOL&4dF!Q>lDbf>V&di!EV{1?xv!_ySIPM8A>&mbJHy&F*J6l zi2`I4fzlZtZ9DRisa?82SVa3Q`PbW66rbcpyp24CpjZfYMIM?w#owAN3c?%YO)y>≈EP;190NEUQnmdbLtMO4fbU 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 a+({1^^*8J?bS-xR8jXz5_lCxk@P7f?@ZnYf literal 0 HcmV?d00001 diff --git a/public/media/refund-process.svg b/public/media/refund-process.svg new file mode 100644 index 0000000..d9547c5 --- /dev/null +++ b/public/media/refund-process.svg @@ -0,0 +1,19 @@ + + 售后退款流程图 + 展示退款申请、订单核验、审核处理和退款完成四个步骤。 + + + + + 售后退款处理流程 + 订单完成后 7 日内可提交退款申请 + + + 1提交申请填写原因与凭证 + 2核验订单确认支付与交付状态 + 3审核处理判断退款范围 + 4退款完成同步处理结果 + + + 数字商品已使用时不支持无理由退款,异常订单需升级售后负责人 + \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6a581d..d2b5aa2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -98,6 +98,7 @@ dependencies = [ "tauri-build", "tauri-plugin-log", "tauri-plugin-shell", + "windows-sys 0.61.2", "xcap", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 196f0bd..fd046c4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,3 +27,4 @@ tauri = { version = "2.11.2", features = ["protocol-asset", "macos-private-api"] tauri-plugin-log = "2" tauri-plugin-shell = "2.3.5" xcap = "0.6" +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..8ea9505 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,50 @@ fn main() { + stop_stale_sidecar(); tauri_build::build() } + +#[cfg(target_os = "windows")] +fn stop_stale_sidecar() { + if std::env::var("PROFILE").as_deref() != Ok("debug") { + return; + } + + let Some(out_dir) = std::env::var_os("OUT_DIR").map(std::path::PathBuf::from) else { + return; + }; + let Some(target_dir) = out_dir + .parent() + .and_then(|path| path.parent()) + .and_then(|path| path.parent()) + .map(|path| path.to_path_buf()) + else { + return; + }; + let sidecar_path = target_dir.join("agent.exe"); + + let script = r#" +$target = [System.IO.Path]::GetFullPath($env:TAURI_SIDECAR_AGENT) +Get-CimInstance Win32_Process -Filter "Name = 'agent.exe'" | + Where-Object { + $_.ExecutablePath -and + ([System.IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) + } | + ForEach-Object { + Stop-Process -Id $_.ProcessId -Force + } +"#; + + let _ = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .env("TAURI_SIDECAR_AGENT", sidecar_path) + .status(); +} + +#[cfg(not(target_os = "windows"))] +fn stop_stale_sidecar() {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1ab9f6..c65bb22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod window_capture; + use screenshots::Screen; use serde::{Deserialize, Serialize}; use std::fs; @@ -5,6 +7,7 @@ use std::path::PathBuf; use std::process::Command; use std::sync::Mutex; use std::time::Instant; +use tauri::webview::PageLoadEvent; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, @@ -15,6 +18,26 @@ use xcap::{Monitor, Window}; struct AgentProcess(Mutex>); struct VisionStreamProcess(Mutex>); +impl Drop for AgentProcess { + fn drop(&mut self) { + if let Ok(mut process) = self.0.lock() { + if let Some(child) = process.take() { + let _ = child.kill(); + } + } + } +} + +impl Drop for VisionStreamProcess { + fn drop(&mut self) { + if let Ok(mut process) = self.0.lock() { + if let Some(child) = process.take() { + let _ = child.kill(); + } + } + } +} + #[derive(Clone, Deserialize, Serialize)] struct AgentLog { level: String, @@ -24,63 +47,63 @@ struct AgentLog { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct CaptureResult { - screenshot_path: String, - screenshot_width: u32, - screenshot_height: u32, - scale_factor: f64, - source: Option, - screen_list_ms: u128, - capture_ms: u128, - save_ms: u128, - total_ms: u128, +pub(crate) struct CaptureResult { + pub(crate) screenshot_path: String, + pub(crate) screenshot_width: u32, + pub(crate) screenshot_height: u32, + pub(crate) scale_factor: f64, + pub(crate) source: Option, + pub(crate) screen_list_ms: u128, + pub(crate) capture_ms: u128, + pub(crate) save_ms: u128, + pub(crate) total_ms: u128, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct CaptureSource { - id: String, - kind: String, - label: String, - app_name: Option, - title: Option, - pid: Option, - x: i32, - y: i32, - width: u32, - height: u32, - scale_factor: f64, +pub(crate) struct CaptureSource { + pub(crate) id: String, + pub(crate) kind: String, + pub(crate) label: String, + pub(crate) app_name: Option, + pub(crate) title: Option, + pub(crate) pid: Option, + pub(crate) x: i32, + pub(crate) y: i32, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) scale_factor: f64, } #[derive(Clone, Deserialize, Serialize)] -struct Region { - id: String, - name: String, - description: Option, +pub(crate) struct Region { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: Option, #[serde(rename = "type")] - region_type: String, - bbox_image: [f64; 4], - bbox_source: Option<[f64; 4]>, - bbox_screen: [f64; 4], + pub(crate) region_type: String, + pub(crate) bbox_image: [f64; 4], + pub(crate) bbox_source: Option<[f64; 4]>, + pub(crate) bbox_screen: [f64; 4], #[serde(rename = "scaleFactor")] - scale_factor: f64, + pub(crate) scale_factor: f64, } #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct AnnotationFile { - app: String, - screenshot_path: String, - screenshot_width: u32, - screenshot_height: u32, - scale_factor: f64, - source: Option, - regions: Vec, - created_at: String, - updated_at: String, +pub(crate) struct AnnotationFile { + pub(crate) app: String, + pub(crate) screenshot_path: String, + pub(crate) screenshot_width: u32, + pub(crate) screenshot_height: u32, + pub(crate) scale_factor: f64, + pub(crate) source: Option, + pub(crate) regions: Vec, + pub(crate) created_at: String, + pub(crate) updated_at: String, } -fn data_dir(app: &tauri::AppHandle) -> Result { +pub(crate) fn data_dir(app: &tauri::AppHandle) -> Result { let dir = app .path() .app_data_dir() @@ -90,7 +113,7 @@ fn data_dir(app: &tauri::AppHandle) -> Result { Ok(dir) } -fn regions_path(app: &tauri::AppHandle) -> Result { +pub(crate) fn regions_path(app: &tauri::AppHandle) -> Result { let dir = data_dir(app)?.join("regions"); fs::create_dir_all(&dir).map_err(|error| error.to_string())?; Ok(dir.join("wechat.json")) @@ -147,6 +170,355 @@ fn save_capture_image( }) } +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +pub(crate) struct WinPhysicalRect { + pub(crate) left: i32, + pub(crate) top: i32, + pub(crate) right: i32, + pub(crate) bottom: i32, +} + +#[cfg(target_os = "windows")] +impl WinPhysicalRect { + pub(crate) fn width(self) -> u32 { + (self.right - self.left).max(0) as u32 + } + + pub(crate) fn height(self) -> u32 { + (self.bottom - self.top).max(0) as u32 + } +} + +#[cfg(target_os = "windows")] +pub(crate) struct WinScreenMatch { + pub(crate) screen: Screen, + pub(crate) physical_x: i32, + pub(crate) physical_y: i32, + pub(crate) physical_width: u32, + pub(crate) physical_height: u32, + pub(crate) logical_x: i32, + pub(crate) logical_y: i32, + pub(crate) logical_width: u32, + pub(crate) logical_height: u32, + pub(crate) scale_factor: f64, +} + +#[cfg(target_os = "windows")] +fn windows_capture_sources() -> Vec { + use windows_sys::Win32::Foundation::{HWND, LPARAM}; + use windows_sys::Win32::UI::WindowsAndMessaging::EnumWindows; + + unsafe extern "system" fn enum_window(hwnd: HWND, lparam: LPARAM) -> i32 { + let sources = &mut *(lparam as *mut Vec); + if let Some(source) = capture_source_from_hwnd(hwnd) { + let duplicate = sources.iter().any(|item| { + item.pid == source.pid + && item.title == source.title + && (item.x - source.x).abs() <= 2 + && (item.y - source.y).abs() <= 2 + && item.width.abs_diff(source.width) <= 2 + && item.height.abs_diff(source.height) <= 2 + }); + if !duplicate { + sources.push(source); + } + } + 1 + } + + let mut sources = Vec::new(); + unsafe { + EnumWindows(Some(enum_window), &mut sources as *mut _ as isize); + } + sources +} + +#[cfg(not(target_os = "windows"))] +fn windows_capture_sources() -> Vec { + Vec::new() +} + +#[cfg(target_os = "windows")] +pub(crate) fn capture_source_from_hwnd( + hwnd: windows_sys::Win32::Foundation::HWND, +) -> Option { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetWindowThreadProcessId, IsIconic, IsWindowVisible, + }; + + unsafe { + if IsWindowVisible(hwnd) == 0 || IsIconic(hwnd) != 0 { + return None; + } + } + + let rect = window_physical_rect(hwnd).ok()?; + if rect.width() < 80 || rect.height() < 80 { + return None; + } + + let title = window_title(hwnd); + let mut pid = 0u32; + unsafe { + GetWindowThreadProcessId(hwnd, &mut pid); + } + let app_name = process_name(pid).unwrap_or_default(); + if app_name.is_empty() && title.is_empty() { + return None; + } + + let screen_match = windows_screen_for_physical_rect(rect).ok()?; + let label_title = if title.is_empty() { + "无标题窗口" + } else { + &title + }; + let label_app = if app_name.is_empty() { + "Windows 应用" + } else { + &app_name + }; + + Some(CaptureSource { + id: format!("win-window:{}", hwnd as usize), + kind: "window".to_string(), + label: format!("{label_app} · {label_title} · Windows"), + app_name: if app_name.is_empty() { + None + } else { + Some(app_name) + }, + title: if title.is_empty() { None } else { Some(title) }, + pid: if pid == 0 { None } else { Some(pid) }, + x: screen_match.logical_x, + y: screen_match.logical_y, + width: screen_match.logical_width, + height: screen_match.logical_height, + scale_factor: screen_match.scale_factor, + }) +} + +#[cfg(target_os = "windows")] +pub(crate) fn window_physical_rect( + hwnd: windows_sys::Win32::Foundation::HWND, +) -> Result { + use std::mem::size_of; + use windows_sys::Win32::Foundation::RECT; + use windows_sys::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}; + use windows_sys::Win32::UI::WindowsAndMessaging::GetWindowRect; + + let mut rect = RECT::default(); + let dwm_result = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS as u32, + &mut rect as *mut _ as *mut core::ffi::c_void, + size_of::() as u32, + ) + }; + + if dwm_result < 0 { + let ok = unsafe { GetWindowRect(hwnd, &mut rect) }; + if ok == 0 { + return Err("GetWindowRect failed".to_string()); + } + } + + if rect.right <= rect.left || rect.bottom <= rect.top { + return Err("window rect is empty".to_string()); + } + + Ok(WinPhysicalRect { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }) +} + +#[cfg(target_os = "windows")] +pub(crate) fn window_title(hwnd: windows_sys::Win32::Foundation::HWND) -> String { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetWindowTextLengthW, GetWindowTextW}; + + let length = unsafe { GetWindowTextLengthW(hwnd) }; + if length <= 0 { + return String::new(); + } + + let mut buffer = vec![0u16; length as usize + 1]; + let copied = unsafe { GetWindowTextW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) }; + if copied <= 0 { + return String::new(); + } + + String::from_utf16_lossy(&buffer[..copied as usize]) + .trim() + .to_string() +} + +#[cfg(target_os = "windows")] +pub(crate) fn process_name(pid: u32) -> Option { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + if pid == 0 { + return None; + } + + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return None; + } + + let mut buffer = vec![0u16; 32768]; + let mut length = buffer.len() as u32; + let ok = unsafe { QueryFullProcessImageNameW(process, 0, buffer.as_mut_ptr(), &mut length) }; + unsafe { + CloseHandle(process); + } + + if ok == 0 || length == 0 { + return None; + } + + let path = String::from_utf16_lossy(&buffer[..length as usize]); + PathBuf::from(path) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) +} + +#[cfg(target_os = "windows")] +pub(crate) fn windows_screen_for_physical_rect( + rect: WinPhysicalRect, +) -> Result { + let screens = Screen::all().map_err(|error| error.to_string())?; + let mut best: Option<(Screen, i32, i32, u32, u32, f64, i64)> = None; + + for screen in screens { + let scale_factor = screen.display_info.scale_factor as f64; + let physical_x = (screen.display_info.x as f64 * scale_factor).round() as i32; + let physical_y = (screen.display_info.y as f64 * scale_factor).round() as i32; + let physical_width = (screen.display_info.width as f64 * scale_factor) + .round() + .max(1.0) as u32; + let physical_height = (screen.display_info.height as f64 * scale_factor) + .round() + .max(1.0) as u32; + let physical_right = physical_x + physical_width as i32; + let physical_bottom = physical_y + physical_height as i32; + + let intersection_width = + (rect.right.min(physical_right) - rect.left.max(physical_x)).max(0) as i64; + let intersection_height = + (rect.bottom.min(physical_bottom) - rect.top.max(physical_y)).max(0) as i64; + let intersection_area = intersection_width * intersection_height; + + if intersection_area > best.map(|item| item.6).unwrap_or(-1) { + best = Some(( + screen, + physical_x, + physical_y, + physical_width, + physical_height, + scale_factor, + intersection_area, + )); + } + } + + let (screen, physical_x, physical_y, physical_width, physical_height, scale_factor, area) = + best.ok_or_else(|| "no screen found".to_string())?; + if area <= 0 { + return Err("window is outside all screens".to_string()); + } + + let logical_x = + screen.display_info.x + ((rect.left - physical_x) as f64 / scale_factor).round() as i32; + let logical_y = + screen.display_info.y + ((rect.top - physical_y) as f64 / scale_factor).round() as i32; + let logical_width = (rect.width() as f64 / scale_factor).round().max(1.0) as u32; + let logical_height = (rect.height() as f64 / scale_factor).round().max(1.0) as u32; + + Ok(WinScreenMatch { + screen, + physical_x, + physical_y, + physical_width, + physical_height, + logical_x, + logical_y, + logical_width, + logical_height, + scale_factor, + }) +} + +#[cfg(target_os = "windows")] +pub(crate) fn capture_windows_window( + hwnd_text: &str, + screenshot_path: PathBuf, + screen_list_ms: u128, + total_started_at: Instant, +) -> Result { + let hwnd_value = hwnd_text + .parse::() + .map_err(|error| error.to_string())?; + let hwnd = hwnd_value as windows_sys::Win32::Foundation::HWND; + let rect = window_physical_rect(hwnd)?; + let source = + capture_source_from_hwnd(hwnd).ok_or_else(|| "window source not found".to_string())?; + let screen_match = windows_screen_for_physical_rect(rect)?; + + let physical_right = screen_match.physical_x + screen_match.physical_width as i32; + let physical_bottom = screen_match.physical_y + screen_match.physical_height as i32; + let x1 = rect.left.max(screen_match.physical_x); + let y1 = rect.top.max(screen_match.physical_y); + let x2 = rect.right.min(physical_right); + let y2 = rect.bottom.min(physical_bottom); + + if x1 >= x2 || y1 >= y2 { + return Err("window source is outside selected screen".to_string()); + } + + let capture_started_at = Instant::now(); + let image = screen_match + .screen + .capture_area_ignore_area_check( + x1 - screen_match.physical_x, + y1 - screen_match.physical_y, + (x2 - x1) as u32, + (y2 - y1) as u32, + ) + .map_err(|error| error.to_string())?; + let capture_ms = capture_started_at.elapsed().as_millis(); + + save_capture_image( + image.width(), + image.height(), + image.into_raw(), + screenshot_path, + Some(source.clone()), + source.scale_factor, + screen_list_ms, + capture_ms, + total_started_at, + ) +} + +#[cfg(not(target_os = "windows"))] +fn capture_windows_window( + _hwnd_text: &str, + _screenshot_path: PathBuf, + _screen_list_ms: u128, + _total_started_at: Instant, +) -> Result { + Err("Windows window capture is only available on Windows".to_string()) +} + fn emit_agent_log(app: &tauri::AppHandle, level: &str, message: impl Into) { let _ = app.emit( "agent-log", @@ -449,6 +821,20 @@ async fn list_capture_sources() -> Result, String> { } } + for source in windows_capture_sources() { + let duplicate = sources.iter().any(|item| { + item.pid == source.pid + && item.title == source.title + && (item.x - source.x).abs() <= 2 + && (item.y - source.y).abs() <= 2 + && item.width.abs_diff(source.width) <= 2 + && item.height.abs_diff(source.height) <= 2 + }); + if !duplicate { + sources.push(source); + } + } + Ok(sources) }) .await @@ -559,18 +945,112 @@ async fn capture_source(app: tauri::AppHandle, source_id: String) -> Result Result, String> { + let screenshots_dir = data_dir(&app)?.join("screenshots"); + fs::create_dir_all(&screenshots_dir).map_err(|error| error.to_string())?; + let screenshots_dir = screenshots_dir + .canonicalize() + .map_err(|error| error.to_string())?; + let requested_path = PathBuf::from(path) + .canonicalize() + .map_err(|error| error.to_string())?; + + if !requested_path.starts_with(&screenshots_dir) { + return Err("screenshot path is outside app screenshot directory".to_string()); + } + + fs::read(requested_path).map_err(|error| error.to_string()) +} + +pub(crate) fn write_annotation_file_atomic( + app: &tauri::AppHandle, + annotation: &AnnotationFile, +) -> Result { + use std::io::Write; + + let path = regions_path(app)?; + let bytes = serde_json::to_vec_pretty(annotation).map_err(|error| error.to_string())?; + let parent = path + .parent() + .ok_or_else(|| "regions path has no parent directory".to_string())?; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos(); + let temp_path = parent.join(format!(".wechat.json.{}.{}.tmp", std::process::id(), nonce)); + + let result = (|| -> Result<(), String> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .map_err(|error| error.to_string())?; + file.write_all(&bytes).map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + drop(file); + + #[cfg(target_os = "windows")] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = temp_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + } + + #[cfg(not(target_os = "windows"))] + fs::rename(&temp_path, &path).map_err(|error| error.to_string())?; + + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result?; + Ok(path) +} + #[tauri::command] fn save_regions(app: tauri::AppHandle, annotation: AnnotationFile) -> Result { - let path = regions_path(&app)?; - let json = serde_json::to_string_pretty(&annotation).map_err(|error| error.to_string())?; - fs::write(&path, json).map_err(|error| error.to_string())?; - Ok(path.to_string_lossy().to_string()) + write_annotation_file_atomic(&app, &annotation).map(|path| path.to_string_lossy().to_string()) } #[tauri::command] @@ -587,59 +1067,120 @@ fn load_regions(app: tauri::AppHandle) -> Result, String> } #[tauri::command] -fn open_popup_window(app: tauri::AppHandle, route: String) -> Result<(), String> { +fn close_current_window(window: tauri::WebviewWindow) -> Result<(), String> { + window.close().map_err(|error| error.to_string()) +} + +#[tauri::command] +fn exit_application(app: tauri::AppHandle) { + app.exit(0); +} + +#[cfg(target_os = "windows")] +fn apply_native_window_corner(window: &tauri::WebviewWindow) -> Result<(), String> { + use std::ffi::c_void; + use std::mem::size_of_val; + use windows_sys::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, + }; + + let hwnd = window.hwnd().map_err(|error| error.to_string())?; + let preference = DWMWCP_ROUND; + let result = unsafe { + DwmSetWindowAttribute( + hwnd.0 as _, + DWMWA_WINDOW_CORNER_PREFERENCE as u32, + &preference as *const _ as *const c_void, + size_of_val(&preference) as u32, + ) + }; + if result < 0 { + return Err(format!( + "DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE) failed: 0x{:08X}", + result as u32 + )); + } + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn apply_native_window_corner(_window: &tauri::WebviewWindow) -> Result<(), String> { + Ok(()) +} + +#[tauri::command] +async fn open_popup_window(app: tauri::AppHandle, route: String) -> Result<(), String> { let route = if route.starts_with('/') { route } else { format!("/{route}") }; - let label = format!("popup-{}", route.trim_start_matches('/').replace('/', "-")); + let route_path = route.split('?').next().unwrap_or(route.as_str()); + let template_label = format!( + "popup-{}", + route_path.trim_start_matches('/').replace('/', "-") + ); + let window_label = format!( + "popup-{}", + route + .trim_start_matches('/') + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect::() + ); - if let Some(window) = app.get_webview_window(&label) { - if route == "/window/engine-workbench" { - window - .set_fullscreen(false) - .map_err(|error| error.to_string())?; - window.maximize().map_err(|error| error.to_string())?; + if route_path == "/annotate" { + if let Some(window) = app.get_webview_window(&window_label) { + let _ = window.close(); } + } else if let Some(window) = app.get_webview_window(&window_label) { + if let Err(error) = apply_native_window_corner(&window) { + eprintln!("failed to apply native window corner: {error}"); + } + window.center().map_err(|error| error.to_string())?; + window.show().map_err(|error| error.to_string())?; window.set_focus().map_err(|error| error.to_string())?; return Ok(()); } - let title = match route.as_str() { - "/annotate" => "屏幕区域标注", - "/window/settings" => "设置", - "/window/engine-workbench" => "微信引擎工作台", - "/window/engine-logs" => "分身引擎日志", - "/window/knowledge-create" => "新增知识库", - "/window/knowledge-detail" => "知识库详情", - "/window/skill-create" => "skill技能详情", - "/window/employee-detail" => "员工蒸馏详情", - "/window/distill-start" => "开始蒸馏", - _ => "二级窗口", - }; + let mut window_config = app + .config() + .app + .windows + .iter() + .find(|window| window.label == template_label) + .cloned() + .ok_or_else(|| format!("popup window route is not configured: {route_path}"))?; + window_config.label = window_label; + window_config.url = WebviewUrl::App(format!("index.html#{route}").into()); + window_config.visible = false; - let mut builder = WebviewWindowBuilder::new( - &app, - label, - WebviewUrl::App(format!("index.html#{route}").into()), - ) - .title(title) - .inner_size(1080.0, 800.0) - .decorations(false) - .transparent(true) - .resizable(true); - - if route == "/annotate" { - builder = builder.maximized(true).fullscreen(true); - } else if route == "/window/engine-workbench" { - builder = builder - .inner_size(1440.0, 920.0) - .maximized(true) - .fullscreen(false); - } - - builder.build().map_err(|error| error.to_string())?; + let _window = WebviewWindowBuilder::from_config(&app, &window_config) + .map_err(|error| error.to_string())? + .on_page_load(move |window, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) { + if let Err(error) = apply_native_window_corner(&window) { + eprintln!("failed to apply native popup corner: {error}"); + } + if let Err(error) = window.center() { + eprintln!("failed to center loaded popup window: {error}"); + } + if let Err(error) = window.show() { + eprintln!("failed to show loaded popup window: {error}"); + } + if let Err(error) = window.set_focus() { + eprintln!("failed to focus loaded popup window: {error}"); + } + } + }) + .build() + .map_err(|error| error.to_string())?; Ok(()) } @@ -649,6 +1190,7 @@ pub fn run() { tauri::Builder::default() .manage(AgentProcess(Mutex::new(None))) .manage(VisionStreamProcess(Mutex::new(None))) + .manage(window_capture::OverlayState::default()) .plugin(tauri_plugin_shell::init()) .setup(|app| { if cfg!(debug_assertions) { @@ -658,19 +1200,39 @@ pub fn run() { .build(), )?; } + #[cfg(target_os = "windows")] + { + window_capture::create_overlay_window(app.handle())?; + if let Some(main_window) = app.get_webview_window("main") { + if let Err(error) = apply_native_window_corner(&main_window) { + eprintln!("failed to apply native main-window corner: {error}"); + } + } + } Ok(()) }) .invoke_handler(tauri::generate_handler![ + close_current_window, + exit_application, capture_source, capture_screen, list_capture_sources, load_regions, open_popup_window, + read_screenshot_bytes, save_regions, start_agent, start_vision_stream, stop_vision_stream, - stop_agent + stop_agent, + window_capture::add_annotation, + window_capture::delete_annotation, + window_capture::enter_window_select_mode, + window_capture::get_overlay_frame, + window_capture::hide_overlay, + window_capture::select_target_window, + window_capture::update_annotation, + window_capture::update_annotation_geometry ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/window_capture.rs b/src-tauri/src/window_capture.rs new file mode 100644 index 0000000..455945d --- /dev/null +++ b/src-tauri/src/window_capture.rs @@ -0,0 +1,1568 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, State}; + +use crate::{AnnotationFile, CaptureResult, CaptureSource, Region}; + +const OVERLAY_LABEL: &str = "popup-annotation-overlay"; +const MIN_ANNOTATION_SIZE: i32 = 6; +#[cfg(not(target_os = "windows"))] +const UNSUPPORTED_MESSAGE: &str = "窗口选择标注当前仅支持 Windows"; +const TARGET_OUTSIDE_SCREEN_MESSAGE: &str = + "目标窗口跨越屏幕或超出屏幕范围,请移动到单个屏幕后重试"; +const TARGET_CLOSED_MESSAGE: &str = "目标窗口已关闭,请重新选择"; +const REGION_TYPES: [&str; 6] = [ + "contact_list", + "chat_content", + "input_box", + "unread_message", + "send_button", + "custom", +]; +const PRESET_REGION_TYPES: [&str; 4] = [ + "contact_list", + "chat_content", + "input_box", + "unread_message", +]; + +#[derive(Clone, Default)] +pub(crate) struct OverlayState(pub(crate) Arc>); + +#[derive(Clone, Default)] +pub(crate) struct OverlayStore { + mode: OverlayMode, + target_window_id: Option, + capture: Option, + annotations: Vec, + next_annotation_index: u32, + created_at: Option, + session_screenshot_path: Option, + session_persisted: bool, + error: Option, + target_physical_rect: Option, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum OverlayMode { + #[default] + Hidden, + WindowSelect, + Annotation, +} + +impl OverlayMode { + fn as_str(self) -> &'static str { + match self { + Self::Hidden => "hidden", + Self::WindowSelect => "window_select", + Self::Annotation => "annotation", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RectInfo { + x: i32, + y: i32, + width: i32, + height: i32, +} + +#[derive(Clone, Copy, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct CursorPoint { + x: i32, + y: i32, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WindowInfo { + window_id: String, + title: String, + process_name: String, + rect: RectInfo, + dpi: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OverlayAnnotation { + id: String, + window_id: String, + name: String, + region_type: String, + description: Option, + screen_rect: RectInfo, + bbox_source: [f64; 4], + bbox_image: [f64; 4], +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OverlayFrame { + mode: String, + virtual_screen_rect: RectInfo, + target_window: Option, + hover_window: Option, + cursor: CursorPoint, + annotations: Vec, + error: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddAnnotationInput { + window_id: String, + screen_rect: RectInfo, + name: Option, + region_type: Option, + description: Option, + updated_at: String, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateAnnotationInput { + annotation_id: String, + name: String, + region_type: String, + description: Option, + updated_at: String, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateAnnotationGeometryInput { + annotation_id: String, + screen_rect: RectInfo, + updated_at: String, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteAnnotationInput { + annotation_id: String, + updated_at: String, +} + +#[tauri::command] +pub(crate) fn enter_window_select_mode( + app: AppHandle, + state: State<'_, OverlayState>, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return enter_window_select_mode_windows(&app, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn get_overlay_frame(state: State<'_, OverlayState>) -> Result { + #[cfg(target_os = "windows")] + { + return get_overlay_frame_windows(&state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = state; + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn select_target_window( + window_id: String, + app: AppHandle, + state: State<'_, OverlayState>, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return select_target_window_windows(window_id, &app, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (window_id, app, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn add_annotation( + app: AppHandle, + input: AddAnnotationInput, + state: State<'_, OverlayState>, +) -> Result { + #[cfg(target_os = "windows")] + { + return add_annotation_windows(&app, input, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, input, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn update_annotation( + app: AppHandle, + input: UpdateAnnotationInput, + state: State<'_, OverlayState>, +) -> Result { + #[cfg(target_os = "windows")] + { + return update_annotation_windows(&app, input, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, input, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn update_annotation_geometry( + app: AppHandle, + input: UpdateAnnotationGeometryInput, + state: State<'_, OverlayState>, +) -> Result { + #[cfg(target_os = "windows")] + { + return update_annotation_geometry_windows(&app, input, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, input, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn delete_annotation( + app: AppHandle, + input: DeleteAnnotationInput, + state: State<'_, OverlayState>, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return delete_annotation_windows(&app, input, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, input, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[tauri::command] +pub(crate) fn hide_overlay(app: AppHandle, state: State<'_, OverlayState>) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return hide_overlay_windows(&app, &state); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, state); + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn create_overlay_window(app: &AppHandle) -> tauri::Result<()> { + use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; + + if app.get_webview_window(OVERLAY_LABEL).is_some() { + return Ok(()); + } + + WebviewWindowBuilder::new(app, OVERLAY_LABEL, WebviewUrl::App("overlay.html".into())) + .title("微信 RPA 标注覆盖层") + .transparent(true) + .decorations(false) + .resizable(false) + .skip_taskbar(true) + .always_on_top(true) + .focused(false) + .visible(false) + .shadow(false) + .build()?; + + Ok(()) +} + +fn normalize_rect(rect: RectInfo) -> RectInfo { + let x2 = rect.x.saturating_add(rect.width); + let y2 = rect.y.saturating_add(rect.height); + let left = rect.x.min(x2); + let top = rect.y.min(y2); + let right = rect.x.max(x2); + let bottom = rect.y.max(y2); + RectInfo { + x: left, + y: top, + width: right - left, + height: bottom - top, + } +} + +fn rect_inside(inner: &RectInfo, outer: &RectInfo) -> bool { + inner.x >= outer.x + && inner.y >= outer.y + && inner.x.saturating_add(inner.width) <= outer.x.saturating_add(outer.width) + && inner.y.saturating_add(inner.height) <= outer.y.saturating_add(outer.height) +} + +fn validate_geometry(rect: RectInfo, target: RectInfo) -> Result { + let normalized = normalize_rect(rect); + if normalized.width < MIN_ANNOTATION_SIZE || normalized.height < MIN_ANNOTATION_SIZE { + return Err("标注框尺寸过小".to_string()); + } + if !rect_inside(&normalized, &target) { + return Err("标注框必须完整位于目标窗口内部".to_string()); + } + Ok(normalized) +} + +fn validate_region_type(region_type: &str) -> Result<(), String> { + if REGION_TYPES.contains(®ion_type) { + Ok(()) + } else { + Err(format!("不支持的区域类型:{region_type}")) + } +} + +fn validate_unique_region_type( + annotations: &[OverlayAnnotation], + region_type: &str, + excluding_id: Option<&str>, +) -> Result<(), String> { + if region_type != "custom" + && annotations.iter().any(|annotation| { + annotation.region_type == region_type && excluding_id != Some(annotation.id.as_str()) + }) + { + return Err(format!("区域类型已存在:{region_type}")); + } + Ok(()) +} + +fn missing_preset_region_types(annotations: &[OverlayAnnotation]) -> Vec<&'static str> { + PRESET_REGION_TYPES + .iter() + .copied() + .filter(|region_type| { + !annotations + .iter() + .any(|annotation| annotation.region_type == *region_type) + }) + .collect() +} + +fn round_two(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} + +fn coordinates_for_rect( + screen_rect: RectInfo, + target_rect: RectInfo, + source: &CaptureSource, + screenshot_width: u32, + screenshot_height: u32, +) -> Result<([f64; 4], [f64; 4], [f64; 4]), String> { + let screen_rect = validate_geometry(screen_rect, target_rect)?; + if !source.scale_factor.is_finite() || source.scale_factor <= 0.0 { + return Err("无效的窗口缩放比例".to_string()); + } + + let bbox_image = [ + (screen_rect.x - target_rect.x) as f64, + (screen_rect.y - target_rect.y) as f64, + (screen_rect.x + screen_rect.width - target_rect.x) as f64, + (screen_rect.y + screen_rect.height - target_rect.y) as f64, + ]; + if bbox_image[0] < 0.0 + || bbox_image[1] < 0.0 + || bbox_image[2] > screenshot_width as f64 + || bbox_image[3] > screenshot_height as f64 + { + return Err("标注框超出截图范围".to_string()); + } + + let bbox_image = bbox_image.map(round_two); + let bbox_source = bbox_image.map(|value| round_two(value / source.scale_factor)); + let bbox_screen = [ + round_two(source.x as f64 + bbox_source[0]), + round_two(source.y as f64 + bbox_source[1]), + round_two(source.x as f64 + bbox_source[2]), + round_two(source.y as f64 + bbox_source[3]), + ]; + Ok((bbox_image, bbox_source, bbox_screen)) +} + +fn same_source(saved: Option<&CaptureSource>, current: &CaptureSource) -> bool { + let Some(saved) = saved else { + return false; + }; + if saved.id == current.id { + return true; + } + matches!( + ( + saved.app_name.as_deref(), + saved.title.as_deref(), + current.app_name.as_deref(), + current.title.as_deref(), + ), + (Some(saved_app), Some(saved_title), Some(current_app), Some(current_title)) + if !saved_app.is_empty() + && !saved_title.is_empty() + && saved_app == current_app + && saved_title == current_title + ) +} + +fn restore_annotations( + saved: &AnnotationFile, + current_source: &CaptureSource, + target_rect: RectInfo, + screenshot_width: u32, + screenshot_height: u32, + window_id: &str, +) -> Vec { + saved + .regions + .iter() + .filter_map(|region| { + let bbox_source = region.bbox_source.unwrap_or_else(|| { + let scale = if region.scale_factor > 0.0 { + region.scale_factor + } else { + 1.0 + }; + region.bbox_image.map(|value| round_two(value / scale)) + }); + let bbox_image = + bbox_source.map(|value| round_two(value * current_source.scale_factor)); + let screen_rect = RectInfo { + x: target_rect.x + bbox_image[0].round() as i32, + y: target_rect.y + bbox_image[1].round() as i32, + width: (bbox_image[2] - bbox_image[0]).round() as i32, + height: (bbox_image[3] - bbox_image[1]).round() as i32, + }; + if validate_geometry(screen_rect, target_rect).is_err() + || bbox_image[0] < 0.0 + || bbox_image[1] < 0.0 + || bbox_image[2] > screenshot_width as f64 + || bbox_image[3] > screenshot_height as f64 + { + return None; + } + Some(OverlayAnnotation { + id: region.id.clone(), + window_id: window_id.to_string(), + name: region.name.clone(), + region_type: region.region_type.clone(), + description: region.description.clone(), + screen_rect, + bbox_source, + bbox_image, + }) + }) + .collect() +} + +fn next_region_index(annotations: &[OverlayAnnotation]) -> u32 { + annotations + .iter() + .filter_map(|annotation| annotation.name.strip_prefix("区域 ")?.parse::().ok()) + .max() + .unwrap_or(annotations.len() as u32) +} + +fn annotation_file_from_store( + store: &OverlayStore, + updated_at: &str, +) -> Result { + let capture = store + .capture + .as_ref() + .ok_or_else(|| "当前没有可保存的窗口截图".to_string())?; + let source = capture + .source + .as_ref() + .ok_or_else(|| "窗口截图缺少来源信息".to_string())?; + let target_rect = store + .target_physical_rect + .ok_or_else(|| "当前没有目标窗口".to_string())?; + + let regions = store + .annotations + .iter() + .map(|annotation| { + let (bbox_image, bbox_source, bbox_screen) = coordinates_for_rect( + annotation.screen_rect, + target_rect, + source, + capture.screenshot_width, + capture.screenshot_height, + )?; + Ok(Region { + id: annotation.id.clone(), + name: annotation.name.clone(), + description: annotation.description.clone(), + region_type: annotation.region_type.clone(), + bbox_image, + bbox_source: Some(bbox_source), + bbox_screen, + scale_factor: round_two(capture.scale_factor), + }) + }) + .collect::, String>>()?; + + Ok(AnnotationFile { + app: "wechat".to_string(), + screenshot_path: capture.screenshot_path.clone(), + screenshot_width: capture.screenshot_width, + screenshot_height: capture.screenshot_height, + scale_factor: round_two(capture.scale_factor), + source: Some(source.clone()), + regions, + created_at: store + .created_at + .clone() + .unwrap_or_else(|| updated_at.to_string()), + updated_at: updated_at.to_string(), + }) +} + +fn trim_optional(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn safe_delete_old_screenshot(app: &AppHandle, old_path: &str, current_path: &Path) { + let old_path = PathBuf::from(old_path); + if old_path == current_path || !old_path.is_file() { + return; + } + let Ok(screenshot_dir) = crate::data_dir(app).map(|path| path.join("screenshots")) else { + return; + }; + let (Ok(old_canonical), Ok(dir_canonical)) = + (old_path.canonicalize(), screenshot_dir.canonicalize()) + else { + return; + }; + if old_canonical.starts_with(dir_canonical) { + let _ = std::fs::remove_file(old_canonical); + } +} + +fn cleanup_unpersisted_session(store: &mut OverlayStore) { + if !store.session_persisted { + if let Some(path) = store.session_screenshot_path.take() { + let _ = std::fs::remove_file(path); + } + } +} + +fn reset_target(store: &mut OverlayStore) { + store.target_window_id = None; + store.capture = None; + store.annotations.clear(); + store.next_annotation_index = 0; + store.created_at = None; + store.session_screenshot_path = None; + store.session_persisted = false; + store.target_physical_rect = None; +} + +fn update_overlay_coordinates(store: &mut OverlayStore) -> Result<(), String> { + let capture = store + .capture + .as_ref() + .ok_or_else(|| "当前没有可保存的窗口截图".to_string())?; + let source = capture + .source + .as_ref() + .ok_or_else(|| "窗口截图缺少来源信息".to_string())? + .clone(); + let screenshot_width = capture.screenshot_width; + let screenshot_height = capture.screenshot_height; + let target_rect = store + .target_physical_rect + .ok_or_else(|| "当前没有目标窗口".to_string())?; + for annotation in &mut store.annotations { + let (bbox_image, bbox_source, _) = coordinates_for_rect( + annotation.screen_rect, + target_rect, + &source, + screenshot_width, + screenshot_height, + )?; + annotation.bbox_image = bbox_image; + annotation.bbox_source = bbox_source; + } + Ok(()) +} + +fn persist_proposed_store( + app: &AppHandle, + current: &mut OverlayStore, + mut proposed: OverlayStore, + updated_at: &str, +) -> Result<(), String> { + if updated_at.trim().is_empty() { + let message = "updatedAt 不能为空".to_string(); + current.error = Some(message.clone()); + return Err(message); + } + if proposed.created_at.is_none() { + proposed.created_at = Some(updated_at.to_string()); + } + if let Err(error) = update_overlay_coordinates(&mut proposed) { + current.error = Some(error.clone()); + return Err(error); + } + let annotation = match annotation_file_from_store(&proposed, updated_at) { + Ok(annotation) => annotation, + Err(error) => { + current.error = Some(error.clone()); + return Err(error); + } + }; + let previous = match crate::load_regions(app.clone()) { + Ok(previous) => previous, + Err(error) => { + current.error = Some(error.clone()); + return Err(error); + } + }; + if let Err(error) = crate::write_annotation_file_atomic(app, &annotation) { + current.error = Some(error.clone()); + return Err(error); + } + + if let Some(previous) = previous { + safe_delete_old_screenshot( + app, + &previous.screenshot_path, + Path::new(&annotation.screenshot_path), + ); + } + proposed.session_persisted = true; + proposed.error = None; + let annotation_complete = missing_preset_region_types(&proposed.annotations).is_empty(); + *current = proposed; + use tauri::Emitter; + let _ = app.emit("annotation-completion-changed", annotation_complete); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn enter_window_select_mode_windows( + app: &AppHandle, + state: &State<'_, OverlayState>, +) -> Result<(), String> { + { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + cleanup_unpersisted_session(&mut store); + reset_target(&mut store); + store.mode = OverlayMode::WindowSelect; + store.error = None; + } + if let Err(error) = show_overlay_window(app) { + if let Ok(mut store) = state.0.lock() { + store.mode = OverlayMode::Hidden; + store.error = Some(error.clone()); + } + return Err(error); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn get_overlay_frame_windows(state: &State<'_, OverlayState>) -> Result { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + + let mut target_window = None; + if store.mode == OverlayMode::Annotation { + target_window = store + .target_window_id + .as_deref() + .and_then(find_window_by_id); + if target_window.is_none() { + cleanup_unpersisted_session(&mut store); + reset_target(&mut store); + store.mode = OverlayMode::WindowSelect; + store.error = Some(TARGET_CLOSED_MESSAGE.to_string()); + } + } + + let hover_window = (store.mode == OverlayMode::WindowSelect) + .then(hover_target) + .flatten(); + Ok(OverlayFrame { + mode: store.mode.as_str().to_string(), + virtual_screen_rect: virtual_screen_rect(), + target_window, + hover_window, + cursor: current_cursor(), + annotations: if store.mode == OverlayMode::Annotation { + store.annotations.clone() + } else { + Vec::new() + }, + error: store.error.clone(), + }) +} + +#[cfg(target_os = "windows")] +fn select_target_window_windows( + window_id: String, + app: &AppHandle, + state: &State<'_, OverlayState>, +) -> Result<(), String> { + let hwnd = match parse_hwnd(&window_id) { + Ok(hwnd) => hwnd, + Err(error) => return record_error(state, error), + }; + let window = match window_info_from_hwnd(hwnd) { + Some(window) => window, + None => return record_error(state, "目标窗口不存在".to_string()), + }; + let physical_rect = crate::WinPhysicalRect { + left: window.rect.x, + top: window.rect.y, + right: window.rect.x + window.rect.width, + bottom: window.rect.y + window.rect.height, + }; + let screen = match crate::windows_screen_for_physical_rect(physical_rect) { + Ok(screen) => screen, + Err(_) => return record_error(state, TARGET_OUTSIDE_SCREEN_MESSAGE.to_string()), + }; + let screen_right = screen.physical_x + screen.physical_width as i32; + let screen_bottom = screen.physical_y + screen.physical_height as i32; + if physical_rect.left < screen.physical_x + || physical_rect.top < screen.physical_y + || physical_rect.right > screen_right + || physical_rect.bottom > screen_bottom + { + return record_error(state, TARGET_OUTSIDE_SCREEN_MESSAGE.to_string()); + } + + hide_overlay_window(app)?; + bring_window_to_front(hwnd); + std::thread::sleep(std::time::Duration::from_millis(80)); + + let session_path = match next_session_screenshot_path(app) { + Ok(path) => path, + Err(error) => { + let _ = show_overlay_window(app); + return record_error(state, error); + } + }; + let capture = crate::capture_windows_window( + &window_id, + session_path.clone(), + 0, + std::time::Instant::now(), + ); + let capture = match capture { + Ok(capture) => capture, + Err(error) => { + let _ = std::fs::remove_file(&session_path); + let _ = show_overlay_window(app); + return record_error(state, error); + } + }; + let source = match capture.source.as_ref() { + Some(source) => source.clone(), + None => { + let error = "窗口截图缺少来源信息".to_string(); + let _ = std::fs::remove_file(&session_path); + let _ = show_overlay_window(app); + return record_error(state, error); + } + }; + let saved = match crate::load_regions(app.clone()) { + Ok(saved) => saved, + Err(error) => { + let _ = std::fs::remove_file(&session_path); + let _ = show_overlay_window(app); + return record_error(state, error); + } + }; + let is_same_source = saved + .as_ref() + .is_some_and(|annotation| same_source(annotation.source.as_ref(), &source)); + let annotations = if is_same_source { + restore_annotations( + saved + .as_ref() + .expect("same source requires saved annotation"), + &source, + window.rect, + capture.screenshot_width, + capture.screenshot_height, + &window_id, + ) + } else { + Vec::new() + }; + let created_at = if is_same_source { + saved + .as_ref() + .map(|annotation| annotation.created_at.clone()) + } else { + None + }; + + if let Err(error) = show_overlay_window(app) { + let _ = std::fs::remove_file(&session_path); + return record_error(state, error); + } + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + cleanup_unpersisted_session(&mut store); + store.mode = OverlayMode::Annotation; + store.target_window_id = Some(window_id); + store.capture = Some(capture); + store.next_annotation_index = next_region_index(&annotations); + store.annotations = annotations; + store.created_at = created_at; + store.session_screenshot_path = Some(session_path); + store.session_persisted = false; + store.error = None; + store.target_physical_rect = Some(window.rect); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn add_annotation_windows( + app: &AppHandle, + input: AddAnnotationInput, + state: &State<'_, OverlayState>, +) -> Result { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + if store.mode != OverlayMode::Annotation + || store.target_window_id.as_deref() != Some(input.window_id.as_str()) + { + return store_error(&mut store, "当前没有选中的目标窗口".to_string()); + } + let target_rect = store + .target_physical_rect + .ok_or_else(|| "当前没有目标窗口".to_string())?; + let screen_rect = match validate_geometry(input.screen_rect, target_rect) { + Ok(rect) => rect, + Err(error) => return store_error(&mut store, error), + }; + let region_type = input.region_type.unwrap_or_else(|| "custom".to_string()); + if let Err(error) = validate_region_type(®ion_type) + .and_then(|_| validate_unique_region_type(&store.annotations, ®ion_type, None)) + { + return store_error(&mut store, error); + } + + let mut proposed = store.clone(); + proposed.next_annotation_index += 1; + let annotation = OverlayAnnotation { + id: format!( + "region-{}-{}", + unix_millis(), + proposed.next_annotation_index + ), + window_id: input.window_id, + name: input + .name + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("区域 {}", proposed.next_annotation_index)), + region_type, + description: trim_optional(input.description), + screen_rect, + bbox_source: [0.0; 4], + bbox_image: [0.0; 4], + }; + let annotation_id = annotation.id.clone(); + proposed.annotations.push(annotation); + persist_proposed_store(app, &mut store, proposed, &input.updated_at)?; + store + .annotations + .iter() + .find(|annotation| annotation.id == annotation_id) + .cloned() + .ok_or_else(|| "标注框不存在".to_string()) +} + +#[cfg(target_os = "windows")] +fn update_annotation_windows( + app: &AppHandle, + input: UpdateAnnotationInput, + state: &State<'_, OverlayState>, +) -> Result { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + let Some(index) = store + .annotations + .iter() + .position(|annotation| annotation.id == input.annotation_id) + else { + return store_error(&mut store, "标注框不存在".to_string()); + }; + if let Err(error) = validate_region_type(&input.region_type).and_then(|_| { + validate_unique_region_type( + &store.annotations, + &input.region_type, + Some(&input.annotation_id), + ) + }) { + return store_error(&mut store, error); + } + + let mut proposed = store.clone(); + let annotation = &mut proposed.annotations[index]; + let name = input.name.trim(); + if !name.is_empty() { + annotation.name = name.to_string(); + } + annotation.region_type = input.region_type; + annotation.description = trim_optional(input.description); + let annotation_id = annotation.id.clone(); + persist_proposed_store(app, &mut store, proposed, &input.updated_at)?; + store + .annotations + .iter() + .find(|annotation| annotation.id == annotation_id) + .cloned() + .ok_or_else(|| "标注框不存在".to_string()) +} + +#[cfg(target_os = "windows")] +fn update_annotation_geometry_windows( + app: &AppHandle, + input: UpdateAnnotationGeometryInput, + state: &State<'_, OverlayState>, +) -> Result { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + let Some(index) = store + .annotations + .iter() + .position(|annotation| annotation.id == input.annotation_id) + else { + return store_error(&mut store, "标注框不存在".to_string()); + }; + let Some(target_rect) = store.target_physical_rect else { + return store_error(&mut store, "当前没有目标窗口".to_string()); + }; + let screen_rect = match validate_geometry(input.screen_rect, target_rect) { + Ok(rect) => rect, + Err(error) => return store_error(&mut store, error), + }; + + let mut proposed = store.clone(); + proposed.annotations[index].screen_rect = screen_rect; + let annotation_id = proposed.annotations[index].id.clone(); + persist_proposed_store(app, &mut store, proposed, &input.updated_at)?; + store + .annotations + .iter() + .find(|annotation| annotation.id == annotation_id) + .cloned() + .ok_or_else(|| "标注框不存在".to_string()) +} + +#[cfg(target_os = "windows")] +fn delete_annotation_windows( + app: &AppHandle, + input: DeleteAnnotationInput, + state: &State<'_, OverlayState>, +) -> Result<(), String> { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + let mut proposed = store.clone(); + let initial_len = proposed.annotations.len(); + proposed + .annotations + .retain(|annotation| annotation.id != input.annotation_id); + if proposed.annotations.len() == initial_len { + return store_error(&mut store, "标注框不存在".to_string()); + } + persist_proposed_store(app, &mut store, proposed, &input.updated_at) +} + +#[cfg(target_os = "windows")] +fn hide_overlay_windows(app: &AppHandle, state: &State<'_, OverlayState>) -> Result<(), String> { + { + let mut store = state + .0 + .lock() + .map_err(|_| "overlay state poisoned".to_string())?; + cleanup_unpersisted_session(&mut store); + reset_target(&mut store); + store.mode = OverlayMode::Hidden; + store.error = None; + } + hide_overlay_window(app) +} + +#[cfg(target_os = "windows")] +fn record_error(state: &State<'_, OverlayState>, error: String) -> Result { + if let Ok(mut store) = state.0.lock() { + store.error = Some(error.clone()); + } + Err(error) +} + +fn store_error(store: &mut OverlayStore, error: String) -> Result { + store.error = Some(error.clone()); + Err(error) +} + +fn unix_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +#[cfg(target_os = "windows")] +fn next_session_screenshot_path(app: &AppHandle) -> Result { + let directory = crate::data_dir(app)?.join("screenshots"); + std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + let mut timestamp = unix_millis(); + loop { + let path = directory.join(format!("annotation-{timestamp}.jpg")); + if !path.exists() { + return Ok(path); + } + timestamp += 1; + } +} + +#[cfg(target_os = "windows")] +fn show_overlay_window(app: &AppHandle) -> Result<(), String> { + use tauri::{Manager, PhysicalPosition, PhysicalSize, Position, Size}; + + let overlay = app + .get_webview_window(OVERLAY_LABEL) + .ok_or_else(|| "overlay window missing".to_string())?; + let rect = virtual_screen_rect(); + overlay + .set_position(Position::Physical(PhysicalPosition::new(rect.x, rect.y))) + .map_err(|error| error.to_string())?; + overlay + .set_size(Size::Physical(PhysicalSize::new( + rect.width.max(1) as u32, + rect.height.max(1) as u32, + ))) + .map_err(|error| error.to_string())?; + overlay.show().map_err(|error| error.to_string())?; + overlay.set_focus().map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn hide_overlay_window(app: &AppHandle) -> Result<(), String> { + use tauri::Manager; + + if let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) { + overlay.hide().map_err(|error| error.to_string())?; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn virtual_screen_rect() -> RectInfo { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, + }; + + unsafe { + RectInfo { + x: GetSystemMetrics(SM_XVIRTUALSCREEN), + y: GetSystemMetrics(SM_YVIRTUALSCREEN), + width: GetSystemMetrics(SM_CXVIRTUALSCREEN), + height: GetSystemMetrics(SM_CYVIRTUALSCREEN), + } + } +} + +#[cfg(target_os = "windows")] +fn current_cursor() -> CursorPoint { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos; + + let mut point = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut point); + } + CursorPoint { + x: point.x, + y: point.y, + } +} + +#[cfg(target_os = "windows")] +fn hover_target() -> Option { + use windows_sys::Win32::Foundation::{HWND, LPARAM, POINT}; + use windows_sys::Win32::UI::WindowsAndMessaging::{EnumWindows, GetCursorPos}; + + struct HoverSearch { + point: POINT, + result: Option, + } + + unsafe extern "system" fn enum_window(hwnd: HWND, lparam: LPARAM) -> i32 { + let search = &mut *(lparam as *mut HoverSearch); + let Some(window) = window_info_from_hwnd(hwnd) else { + return 1; + }; + let rect = window.rect; + let inside = search.point.x >= rect.x + && search.point.x < rect.x + rect.width + && search.point.y >= rect.y + && search.point.y < rect.y + rect.height; + if inside { + search.result = Some(window); + return 0; + } + 1 + } + + let mut search = HoverSearch { + point: POINT::default(), + result: None, + }; + unsafe { + if GetCursorPos(&mut search.point) == 0 { + return None; + } + EnumWindows(Some(enum_window), &mut search as *mut _ as isize); + } + search.result +} + +#[cfg(target_os = "windows")] +fn parse_hwnd(window_id: &str) -> Result { + let raw = window_id + .parse::() + .map_err(|_| "invalid window id".to_string())?; + if raw == 0 { + return Err("invalid window id".to_string()); + } + Ok(raw as windows_sys::Win32::Foundation::HWND) +} + +#[cfg(target_os = "windows")] +fn find_window_by_id(window_id: &str) -> Option { + let hwnd = parse_hwnd(window_id).ok()?; + window_info_from_hwnd(hwnd) +} + +#[cfg(target_os = "windows")] +fn window_info_from_hwnd(hwnd: windows_sys::Win32::Foundation::HWND) -> Option { + use windows_sys::Win32::UI::HiDpi::GetDpiForWindow; + + if !is_selectable_window(hwnd) { + return None; + } + let rect = crate::window_physical_rect(hwnd).ok()?; + let title = crate::window_title(hwnd); + if title.is_empty() { + return None; + } + let mut pid = 0u32; + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(hwnd, &mut pid); + } + Some(WindowInfo { + window_id: (hwnd as usize).to_string(), + title, + process_name: crate::process_name(pid).unwrap_or_else(|| "Unknown.exe".to_string()), + rect: RectInfo { + x: rect.left, + y: rect.top, + width: rect.width() as i32, + height: rect.height() as i32, + }, + dpi: unsafe { GetDpiForWindow(hwnd) }, + }) +} + +#[cfg(target_os = "windows")] +fn is_selectable_window(hwnd: windows_sys::Win32::Foundation::HWND) -> bool { + use windows_sys::Win32::System::Threading::GetCurrentProcessId; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetWindowLongW, GetWindowThreadProcessId, IsIconic, IsWindow, IsWindowVisible, GWL_EXSTYLE, + WS_EX_TOOLWINDOW, + }; + + unsafe { + if IsWindow(hwnd) == 0 || IsWindowVisible(hwnd) == 0 || IsIconic(hwnd) != 0 { + return false; + } + if GetWindowLongW(hwnd, GWL_EXSTYLE) as u32 & WS_EX_TOOLWINDOW != 0 { + return false; + } + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, &mut pid); + if pid == 0 || pid == GetCurrentProcessId() { + return false; + } + } + let excluded_classes = [ + "Progman", + "WorkerW", + "Shell_TrayWnd", + "Shell_SecondaryTrayWnd", + "IME", + "MSCTFIME UI", + "SysShadow", + ]; + if excluded_classes.contains(&window_class_name(hwnd).as_str()) { + return false; + } + let title = crate::window_title(hwnd); + if title.is_empty() { + return false; + } + crate::window_physical_rect(hwnd).is_ok_and(|rect| rect.width() >= 80 && rect.height() >= 80) +} + +#[cfg(target_os = "windows")] +fn window_class_name(hwnd: windows_sys::Win32::Foundation::HWND) -> String { + use windows_sys::Win32::UI::WindowsAndMessaging::GetClassNameW; + + let mut buffer = [0u16; 256]; + let written = unsafe { GetClassNameW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) }; + if written <= 0 { + return String::new(); + } + String::from_utf16_lossy(&buffer[..written as usize]) +} + +#[cfg(target_os = "windows")] +fn bring_window_to_front(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + BringWindowToTop, SetForegroundWindow, ShowWindow, SW_RESTORE, + }; + + unsafe { + ShowWindow(hwnd, SW_RESTORE); + BringWindowToTop(hwnd); + SetForegroundWindow(hwnd); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source(scale_factor: f64, x: i32, y: i32) -> CaptureSource { + CaptureSource { + id: "win-window:10".to_string(), + kind: "window".to_string(), + label: "WeChat".to_string(), + app_name: Some("WeChat.exe".to_string()), + title: Some("微信".to_string()), + pid: Some(10), + x, + y, + width: 800, + height: 600, + scale_factor, + } + } + + fn annotation_file(saved_source: CaptureSource) -> AnnotationFile { + AnnotationFile { + app: "wechat".to_string(), + screenshot_path: "old.jpg".to_string(), + screenshot_width: 800, + screenshot_height: 600, + scale_factor: saved_source.scale_factor, + source: Some(saved_source), + regions: vec![Region { + id: "region-1".to_string(), + name: "区域 1".to_string(), + description: None, + region_type: "custom".to_string(), + bbox_image: [15.0, 30.0, 165.0, 180.0], + bbox_source: Some([10.0, 20.0, 110.0, 120.0]), + bbox_screen: [10.0, 20.0, 110.0, 120.0], + scale_factor: 1.5, + }], + created_at: "2026-01-01T00:00:00.000Z".to_string(), + updated_at: "2026-01-01T00:00:00.000Z".to_string(), + } + } + + #[test] + fn normalizes_forward_and_reverse_drag() { + assert_eq!( + normalize_rect(RectInfo { + x: 10, + y: 20, + width: 30, + height: 40, + }), + RectInfo { + x: 10, + y: 20, + width: 30, + height: 40, + } + ); + assert_eq!( + normalize_rect(RectInfo { + x: 40, + y: 60, + width: -30, + height: -40, + }), + RectInfo { + x: 10, + y: 20, + width: 30, + height: 40, + } + ); + } + + #[test] + fn rejects_small_and_outside_rectangles() { + let target = RectInfo { + x: 0, + y: 0, + width: 100, + height: 100, + }; + assert!(validate_geometry( + RectInfo { + x: 1, + y: 1, + width: 5, + height: 10, + }, + target + ) + .is_err()); + assert!(validate_geometry( + RectInfo { + x: 90, + y: 90, + width: 20, + height: 20, + }, + target + ) + .is_err()); + } + + #[test] + fn converts_scale_one_and_one_point_five() { + let target = RectInfo { + x: 100, + y: 200, + width: 800, + height: 600, + }; + let rect = RectInfo { + x: 115, + y: 230, + width: 150, + height: 150, + }; + let (_, source_one, _) = + coordinates_for_rect(rect, target, &source(1.0, 100, 200), 800, 600).unwrap(); + assert_eq!(source_one, [15.0, 30.0, 165.0, 180.0]); + let (image, source_scaled, _) = + coordinates_for_rect(rect, target, &source(1.5, 10, 20), 800, 600).unwrap(); + assert_eq!(image, [15.0, 30.0, 165.0, 180.0]); + assert_eq!(source_scaled, [10.0, 20.0, 110.0, 120.0]); + } + + #[test] + fn supports_negative_virtual_screen_origin_and_screen_formula() { + let target = RectInfo { + x: -1200, + y: -100, + width: 800, + height: 600, + }; + let rect = RectInfo { + x: -1185, + y: -70, + width: 150, + height: 150, + }; + let (_, bbox_source, bbox_screen) = + coordinates_for_rect(rect, target, &source(1.5, -800, -66), 800, 600).unwrap(); + assert_eq!(bbox_source, [10.0, 20.0, 110.0, 120.0]); + assert_eq!(bbox_screen, [-790.0, -46.0, -690.0, 54.0]); + } + + #[test] + fn rejects_duplicate_semantic_region_types() { + let annotations = vec![OverlayAnnotation { + id: "one".to_string(), + window_id: "10".to_string(), + name: "联系人".to_string(), + region_type: "contact_list".to_string(), + description: None, + screen_rect: RectInfo::default(), + bbox_source: [0.0; 4], + bbox_image: [0.0; 4], + }]; + assert_eq!( + validate_unique_region_type(&annotations, "contact_list", None), + Err("区域类型已存在:contact_list".to_string()) + ); + assert!(validate_unique_region_type(&annotations, "custom", None).is_ok()); + assert!(validate_region_type("unread_message").is_ok()); + } + + #[test] + fn detects_complete_preset_annotation_set() { + let mut annotations = PRESET_REGION_TYPES[..3] + .iter() + .map(|region_type| OverlayAnnotation { + id: (*region_type).to_string(), + window_id: "10".to_string(), + name: (*region_type).to_string(), + region_type: (*region_type).to_string(), + description: None, + screen_rect: RectInfo::default(), + bbox_source: [0.0; 4], + bbox_image: [0.0; 4], + }) + .collect::>(); + assert_eq!( + missing_preset_region_types(&annotations), + vec!["unread_message"] + ); + annotations.push(OverlayAnnotation { + id: "unread_message".to_string(), + window_id: "10".to_string(), + name: "未读消息区域".to_string(), + region_type: "unread_message".to_string(), + description: None, + screen_rect: RectInfo::default(), + bbox_source: [0.0; 4], + bbox_image: [0.0; 4], + }); + assert!(missing_preset_region_types(&annotations).is_empty()); + } + + #[test] + fn serializes_legacy_json_field_names_and_xyxy_arrays() { + let mut store = OverlayStore { + mode: OverlayMode::Annotation, + target_window_id: Some("10".to_string()), + capture: Some(CaptureResult { + screenshot_path: "annotation.jpg".to_string(), + screenshot_width: 800, + screenshot_height: 600, + scale_factor: 1.5, + source: Some(source(1.5, 10, 20)), + screen_list_ms: 0, + capture_ms: 0, + save_ms: 0, + total_ms: 0, + }), + target_physical_rect: Some(RectInfo { + x: 100, + y: 200, + width: 800, + height: 600, + }), + created_at: Some("created".to_string()), + ..OverlayStore::default() + }; + store.annotations.push(OverlayAnnotation { + id: "one".to_string(), + window_id: "10".to_string(), + name: "区域 1".to_string(), + region_type: "custom".to_string(), + description: None, + screen_rect: RectInfo { + x: 115, + y: 230, + width: 150, + height: 150, + }, + bbox_source: [0.0; 4], + bbox_image: [0.0; 4], + }); + let file = annotation_file_from_store(&store, "updated").unwrap(); + let value = serde_json::to_value(file).unwrap(); + assert!(value["regions"][0]["bbox_image"].is_array()); + assert_eq!( + value["regions"][0]["bbox_image"].as_array().unwrap().len(), + 4 + ); + assert!(value["regions"][0].get("window_id").is_none()); + assert!(value["regions"][0].get("action_kind").is_none()); + assert_eq!(value["regions"][0]["scaleFactor"], 1.5); + } + + #[test] + fn restores_same_source_and_clears_different_source() { + let current = source(1.5, 10, 20); + let saved = annotation_file(current.clone()); + assert!(same_source(saved.source.as_ref(), ¤t)); + assert_eq!( + restore_annotations( + &saved, + ¤t, + RectInfo { + x: 100, + y: 200, + width: 800, + height: 600, + }, + 800, + 600, + "10" + ) + .len(), + 1 + ); + let different = source(1.5, 10, 20); + let mut saved_different = saved; + saved_different.source.as_mut().unwrap().id = "other".to_string(); + saved_different.source.as_mut().unwrap().title = Some("其他窗口".to_string()); + assert!(!same_source(saved_different.source.as_ref(), &different)); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b6809c6..2f5244d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,13 +13,160 @@ "macOSPrivateApi": true, "windows": [ { + "label": "main", + "url": "index.html", "title": "微信AI助手", "width": 500, "height": 900, "minWidth": 500, "minHeight": 900, "decorations": false, - "transparent": true, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-annotate", + "create": false, + "url": "index.html#/annotate", + "title": "屏幕区域标注", + "width": 1280, + "height": 860, + "maximized": true, + "fullscreen": false, + "decorations": false, + "transparent": false, + "resizable": true + }, + { + "label": "popup-window-settings", + "create": false, + "url": "index.html#/window/settings", + "title": "设置", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-engine-logs", + "create": false, + "url": "index.html#/window/engine-logs", + "title": "分身引擎日志", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-knowledge-create", + "create": false, + "url": "index.html#/window/knowledge-create", + "title": "新增知识库", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-knowledge-update", + "create": false, + "url": "index.html#/window/knowledge-update", + "title": "更新知识库", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-knowledge-detail", + "create": false, + "url": "index.html#/window/knowledge-detail", + "title": "知识库详情", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-skill-create", + "create": false, + "url": "index.html#/window/skill-create", + "title": "新增智能体", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-skill-update", + "create": false, + "url": "index.html#/window/skill-update", + "title": "更新智能体", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-skill-test", + "create": false, + "url": "index.html#/window/skill-test", + "title": "测试智能体", + "width": 920, + "height": 760, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-skill-detail", + "create": false, + "url": "index.html#/window/skill-detail", + "title": "智能体详情", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-employee-detail", + "create": false, + "url": "index.html#/window/employee-detail", + "title": "员工蒸馏详情", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, + "resizable": true, + "fullscreen": false + }, + { + "label": "popup-window-distill-start", + "create": false, + "url": "index.html#/window/distill-start", + "title": "开始蒸馏", + "width": 1080, + "height": 800, + "decorations": false, + "transparent": false, "resizable": true, "fullscreen": false } diff --git a/src/App.jsx b/src/App.jsx index e69e3c7..b73c902 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,39 @@ -import { useEffect, useState } from "react"; +import { Component, useEffect, useState } from "react"; import MainShell from "./components/MainShell"; import AnnotationPage from "./pages/AnnotationPage"; import SecondaryWindow from "./windows/SecondaryWindow"; +function AppErrorFallback({ error }) { + return ( +

+ ); +} + +class AppErrorBoundary extends Component { + state = { error: null }; + + componentDidCatch(error) { + this.setState({ error }); + } + + render() { + if (this.state.error) { + return ; + } + + return this.props.children; + } +} + function App() { const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light"); const [activeTab, setActiveTab] = useState("clone"); @@ -27,6 +58,20 @@ function App() { return () => window.removeEventListener("storage", onStorage); }, []); + useEffect(() => { + const timers = new WeakMap(); + const revealScrollbar = (event) => { + const target = event.target instanceof Element ? event.target : document.scrollingElement; + if (!target) return; + target.classList.add("scrollbar-active"); + window.clearTimeout(timers.get(target)); + timers.set(target, window.setTimeout(() => target.classList.remove("scrollbar-active"), 700)); + }; + + document.addEventListener("scroll", revealScrollbar, true); + return () => document.removeEventListener("scroll", revealScrollbar, true); + }, []); + useEffect(() => { const onHashChange = () => setRoute(window.location.hash.replace("#", "") || "/"); window.addEventListener("hashchange", onHashChange); @@ -37,30 +82,32 @@ function App() { const isAnnotationRoute = route === "/annotate"; return ( -
- {isAnnotationRoute ? ( - - ) : isWindowRoute ? ( - - ) : ( - - )} -
+ +
+ {isAnnotationRoute ? ( + + ) : isWindowRoute ? ( + + ) : ( + + )} +
+
); } diff --git a/src/components/AgentChatPanel.jsx b/src/components/AgentChatPanel.jsx new file mode 100644 index 0000000..67b9587 --- /dev/null +++ b/src/components/AgentChatPanel.jsx @@ -0,0 +1,206 @@ +import { useEffect, useRef, useState } from "react"; +import { FileText, Paperclip, Send, X } from "lucide-react"; +import { extractDocumentText } from "../utils/documentText"; + +const DEFAULT_INPUT = "客户等级:A\n商品:年度专业版\n客户问题:还能再优惠一点吗?"; +const MAX_FILES = 5; +const MAX_FILE_SIZE = 20 * 1024 * 1024; + +function formatFileSize(size) { + if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +function replyFor(content, documents) { + if (documents.length) { + const readable = documents.filter((document) => document.text); + const names = documents.map((document) => document.name).join("、"); + if (!readable.length) return `已收到附件:${names}。文件未提取到可读文本,请换用 PDF、DOCX、PPTX、TXT 或 Markdown 后重试。`; + const excerpt = readable.map((document) => `${document.name}:${document.text.slice(0, 120).replace(/[。!?!?;;,,\s]+$/, "")}`).join(";"); + return `已结合附件 ${names} 分析。内容要点:${excerpt}。基于当前提示词,建议先核对关键事实,再给出明确结论,并避免承诺未授权的权益。`; + } + if (/优惠|报价|价格/.test(content)) { + return "可以在不突破最低利润率的前提下提供 8% 优惠,建议报价 ¥8,800。回复客户时先说明年度专业版的权益,再强调本次优惠的有效期限。"; + } + return "已收到客户需求。建议先确认使用场景和预算范围,再结合已授权知识给出明确方案,避免承诺未配置的权益。"; +} + +export default function AgentChatPanel({ + allowFiles = false, + initialInput = DEFAULT_INPUT, + onComplete, + onStreamingChange, +}) { + const [input, setInput] = useState(initialInput); + const [messages, setMessages] = useState([ + { id: 0, role: "assistant", content: "我是测试智能体。发送消息或上传文件,我会按当前提示词生成回复。", files: [] }, + ]); + const [attachments, setAttachments] = useState([]); + const [streaming, setStreaming] = useState(false); + const [tested, setTested] = useState(false); + const [error, setError] = useState(""); + const messageId = useRef(0); + const streamTimer = useRef(null); + const scrollAnchor = useRef(null); + const fileInput = useRef(null); + + useEffect(() => () => { + if (streamTimer.current) window.clearInterval(streamTimer.current); + }, []); + + useEffect(() => { + scrollAnchor.current?.scrollIntoView({ block: "end" }); + }, [messages]); + + function updateStreaming(value) { + setStreaming(value); + onStreamingChange?.(value); + } + + function selectFiles(event) { + const selected = Array.from(event.target.files || []); + const oversized = selected.find((file) => file.size > MAX_FILE_SIZE); + if (oversized) { + setError(`${oversized.name} 超过 20 MB,无法添加`); + event.target.value = ""; + return; + } + setError(""); + setAttachments((current) => { + const known = new Set(current.map((file) => `${file.name}:${file.size}:${file.lastModified}`)); + const additions = selected.filter((file) => !known.has(`${file.name}:${file.size}:${file.lastModified}`)); + return [...current, ...additions].slice(0, MAX_FILES); + }); + event.target.value = ""; + } + + async function sendMessage() { + const content = input.trim(); + if ((!content && !attachments.length) || streaming) return; + + const files = attachments; + const userId = ++messageId.current; + const assistantId = ++messageId.current; + setMessages((current) => [ + ...current, + { + id: userId, + role: "user", + content, + files: files.map((file) => ({ name: file.name, size: formatFileSize(file.size) })), + }, + { id: assistantId, role: "assistant", content: "", files: [] }, + ]); + setInput(""); + setAttachments([]); + setTested(false); + setError(""); + updateStreaming(true); + + const documents = await Promise.all(files.map(async (file) => { + try { + return { name: file.name, text: await extractDocumentText(file) }; + } catch { + return { name: file.name, text: "" }; + } + })); + const characters = Array.from(replyFor(content, documents)); + let characterIndex = 0; + + streamTimer.current = window.setInterval(() => { + const character = characters[characterIndex]; + characterIndex += 1; + setMessages((current) => current.map((message) => ( + message.id === assistantId ? { ...message, content: message.content + character } : message + ))); + if (characterIndex >= characters.length) { + window.clearInterval(streamTimer.current); + streamTimer.current = null; + updateStreaming(false); + setTested(true); + onComplete?.(); + } + }, 28); + } + + function handleInputKeyDown(event) { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + void sendMessage(); + } + + return ( +
+
+
+

AI 对话测试

+

使用当前提示词和知识授权验证智能体回复

+
+ + {streaming ? "生成中" : tested ? "测试通过" : "等待对话"} + +
+ +
+ {messages.map((message, index) => ( +
+
+ {message.role === "user" ? "我" : "AI"} +
+
+ {message.files?.length ? ( +
+ {message.files.map((file) => ( +
+ + {file.name} + {file.size} +
+ ))} +
+ ) : null} + {message.content} + {streaming && index === messages.length - 1 && message.role === "assistant" ? | : null} +
+
+ ))} +
+
+ +
+ {attachments.length ? ( +
+ {attachments.map((file) => ( +
+ + {file.name} + +
+ ))} +
+ ) : null} +
+ {allowFiles ? ( + <> + + + + ) : null} + + + + +`; + +const selectionScrim = root.querySelector(".selection-scrim"); +const selectionScrimShape = root.querySelector(".selection-scrim-shape"); +const highlight = root.querySelector(".window-highlight"); +const windowLabel = root.querySelector(".window-label"); +const targetOutline = root.querySelector(".target-outline"); +const presetToolbar = root.querySelector(".region-preset-toolbar"); +const annotationLayer = root.querySelector(".annotation-layer"); +const hint = root.querySelector(".overlay-hint"); +const editor = root.querySelector(".annotation-editor"); +const editorName = editor.elements.namedItem("name"); +const editorType = editor.elements.namedItem("regionType"); +const editorTypeLabel = editor.querySelector("[data-region-type-label]"); +const editorDescription = editor.elements.namedItem("description"); + +let frame = { + mode: "hidden", + virtualScreenRect: { x: 0, y: 0, width: 1, height: 1 }, + targetWindow: null, + hoverWindow: null, + cursor: { x: 0, y: 0 }, + annotations: [], + error: null, +}; +let interaction = null; +let selectedAnnotationId = null; +let editorAnnotationId = null; +let editorPosition = null; +let lastAnnotationClick = null; +let activePresetType = null; +let polling = false; +let commandBusy = false; +let transientMessage = ""; +let transientTimer = null; + +function nowIso() { + return new Date().toISOString(); +} + +function scale() { + const virtual = frame.virtualScreenRect; + return { + x: window.innerWidth / Math.max(virtual.width, 1), + y: window.innerHeight / Math.max(virtual.height, 1), + }; +} + +function physicalPoint(event) { + const virtual = frame.virtualScreenRect; + const currentScale = scale(); + return { + x: Math.round(virtual.x + event.clientX / currentScale.x), + y: Math.round(virtual.y + event.clientY / currentScale.y), + }; +} + +function physicalRectToCss(rect) { + const virtual = frame.virtualScreenRect; + const currentScale = scale(); + return { + left: (rect.x - virtual.x) * currentScale.x, + top: (rect.y - virtual.y) * currentScale.y, + width: rect.width * currentScale.x, + height: rect.height * currentScale.y, + }; +} + +function normalizeRect(rect) { + const x2 = rect.x + rect.width; + const y2 = rect.y + rect.height; + const left = Math.min(rect.x, x2); + const top = Math.min(rect.y, y2); + return { + x: left, + y: top, + width: Math.max(rect.x, x2) - left, + height: Math.max(rect.y, y2) - top, + }; +} + +function clamp(value, minimum, maximum) { + return Math.min(Math.max(value, minimum), maximum); +} + +function clampPointToTarget(point) { + const target = frame.targetWindow?.rect; + if (!target) return point; + return { + x: clamp(point.x, target.x, target.x + target.width), + y: clamp(point.y, target.y, target.y + target.height), + }; +} + +function annotationRect(annotation) { + if ( + interaction && + (interaction.kind === "move" || interaction.kind === "resize") && + interaction.annotationId === annotation.id + ) { + return interaction.currentRect; + } + return annotation.screenRect; +} + +function setBox(element, rect) { + const css = physicalRectToCss(rect); + element.style.left = `${css.left}px`; + element.style.top = `${css.top}px`; + element.style.width = `${Math.max(css.width, 0)}px`; + element.style.height = `${Math.max(css.height, 0)}px`; +} + + +function renderSelectionMask() { + const hover = frame.hoverWindow; + if (frame.mode !== "window_select") { + selectionScrim.hidden = true; + highlight.hidden = true; + return; + } + + const viewportPath = `M0 0H${window.innerWidth}V${window.innerHeight}H0Z`; + selectionScrim.hidden = false; + if (!hover) { + selectionScrimShape.setAttribute("d", viewportPath); + highlight.hidden = true; + return; + } + + const rect = physicalRectToCss(hover.rect); + const right = rect.left + rect.width; + const bottom = rect.top + rect.height; + selectionScrimShape.setAttribute( + "d", + `${viewportPath} M${rect.left} ${rect.top}H${right}V${bottom}H${rect.left}Z`, + ); + highlight.hidden = false; + setBox(highlight, hover.rect); + windowLabel.textContent = `${hover.title} · ${hover.processName} · ${hover.rect.width}×${hover.rect.height} · ${hover.dpi} DPI`; +} + +function renderAnnotations() { + const target = frame.targetWindow; + if (frame.mode !== "annotation" || !target) { + targetOutline.hidden = true; + annotationLayer.replaceChildren(); + return; + } + + targetOutline.hidden = false; + setBox(targetOutline, target.rect); + const existingBoxes = new Map( + [...annotationLayer.querySelectorAll("[data-annotation-id]")].map((box) => [ + box.dataset.annotationId, + box, + ]), + ); + + for (const annotation of frame.annotations) { + let box = existingBoxes.get(annotation.id); + if (!box) { + box = document.createElement("div"); + box.className = "annotation-box"; + box.dataset.annotationId = annotation.id; + const label = document.createElement("span"); + label.className = "annotation-label"; + box.append(label); + annotationLayer.append(box); + } + existingBoxes.delete(annotation.id); + box.classList.toggle("is-selected", annotation.id === selectedAnnotationId); + const rect = annotationRect(annotation); + setBox(box, rect); + + const preset = REGION_PRESET_BY_TYPE.get(annotation.regionType); + const label = preset?.label || annotation.name || annotation.regionType; + const x = rect.x - target.rect.x; + const y = rect.y - target.rect.y; + box.querySelector(".annotation-label").textContent = + `${label}·x:${x},y:${y} w:${rect.width} h:${rect.height}`; + let handle = box.querySelector(".resize-handle"); + if (annotation.id === selectedAnnotationId) { + if (!handle) { + handle = document.createElement("button"); + handle.type = "button"; + handle.className = "resize-handle"; + handle.dataset.resizeHandle = "se"; + box.append(handle); + } + handle.setAttribute("aria-label", `缩放 ${annotation.name}`); + } else { + handle?.remove(); + } + } + for (const staleBox of existingBoxes.values()) staleBox.remove(); + + let draft = annotationLayer.querySelector("[data-annotation-draft]"); + if (interaction?.kind === "draw") { + if (!draft) { + draft = document.createElement("div"); + draft.className = "annotation-box is-draft"; + draft.dataset.annotationDraft = "true"; + annotationLayer.append(draft); + } + setBox(draft, normalizeRect(interaction.currentRect)); + } else { + draft?.remove(); + } +} + +function allPresetRegionsComplete() { + return REGION_PRESETS.every((preset) => + frame.annotations.some((annotation) => annotation.regionType === preset.type), + ); +} + +function renderPresetToolbar() { + const target = frame.targetWindow; + if (frame.mode !== "annotation" || !target) { + activePresetType = null; + presetToolbar.hidden = true; + root.dataset.drawing = "false"; + return; + } + + presetToolbar.hidden = false; + for (const preset of REGION_PRESETS) { + const button = presetToolbar.querySelector(`[data-region-type="${preset.type}"]`); + const existing = frame.annotations.find((annotation) => annotation.regionType === preset.type); + const armed = activePresetType === preset.type && !existing; + const active = Boolean(existing || armed); + button.classList.toggle("is-active", active); + button.classList.toggle("is-armed", armed); + button.setAttribute("aria-pressed", String(active)); + button.querySelector(".preset-state").textContent = existing + ? "已标注" + : armed + ? "请拖动" + : "未标注"; + } + + const rect = physicalRectToCss(target.rect); + const toolbarWidth = presetToolbar.offsetWidth || 176; + const toolbarHeight = presetToolbar.offsetHeight || 260; + const preferredLeft = rect.left + rect.width + 12; + const left = + preferredLeft + toolbarWidth <= window.innerWidth - 12 + ? preferredLeft + : clamp(rect.left + rect.width - toolbarWidth - 12, 12, window.innerWidth - toolbarWidth - 12); + const top = clamp( + rect.top, + 12, + Math.max(window.innerHeight - toolbarHeight - 12, 12), + ); + presetToolbar.style.left = `${left}px`; + presetToolbar.style.top = `${top}px`; + root.dataset.drawing = String(Boolean(activePresetType)); +} + +function renderEditor() { + if (!editorAnnotationId) { + editor.hidden = true; + return; + } + const annotation = frame.annotations.find((item) => item.id === editorAnnotationId); + if (!annotation) { + editorAnnotationId = null; + editor.hidden = true; + return; + } + editor.hidden = false; + if (!editorPosition) { + const rect = physicalRectToCss(annotation.screenRect); + editorPosition = { + left: clamp(rect.left + 12, 12, Math.max(window.innerWidth - 332, 12)), + top: clamp(rect.top + 12, 12, Math.max(window.innerHeight - 390, 12)), + }; + } + editor.style.left = `${editorPosition.left}px`; + editor.style.top = `${editorPosition.top}px`; +} + +function renderHint() { + const backendError = frame.error; + const message = transientMessage || backendError; + hint.classList.toggle("is-error", Boolean(message && message !== "标注已自动保存")); + hint.classList.toggle("is-success", message === "标注已自动保存"); + if (message) { + hint.textContent = message; + } else if (frame.mode === "window_select") { + hint.textContent = "移动鼠标选择窗口,单击确认;Esc 退出标注"; + } else if (frame.mode === "annotation" && activePresetType) { + const label = REGION_PRESET_BY_TYPE.get(activePresetType)?.label || activePresetType; + hint.textContent = `请在微信窗口内拖动绘制“${label}”;每种区域只能标注一个`; + } else if (frame.mode === "annotation" && allPresetRegionsComplete()) { + hint.textContent = "全部预设区域已完成并自动保存,按 Esc 退出标注"; + } else if (frame.mode === "annotation") { + hint.textContent = "标注会自动保存;请继续选择未完成的区域类型,按 Esc 可退出"; + } else { + hint.textContent = ""; + } + hint.hidden = frame.mode === "hidden"; +} + +function render() { + root.dataset.mode = frame.mode; + renderSelectionMask(); + renderPresetToolbar(); + renderAnnotations(); + renderEditor(); + renderHint(); +} + +function showMessage(message, duration = 2200) { + transientMessage = String(message); + if (transientTimer) window.clearTimeout(transientTimer); + transientTimer = window.setTimeout(() => { + transientMessage = ""; + renderHint(); + }, duration); + renderHint(); +} + +async function refreshFrame() { + if (polling) return; + polling = true; + try { + frame = await invoke("get_overlay_frame"); + if ( + selectedAnnotationId && + !frame.annotations.some((annotation) => annotation.id === selectedAnnotationId) + ) { + selectedAnnotationId = null; + } + render(); + } catch (error) { + showMessage(error); + } finally { + polling = false; + } +} + +async function runCommand(command, payload, successMessage = "") { + if (commandBusy) return null; + commandBusy = true; + try { + const result = await invoke(command, payload); + if (successMessage) showMessage(successMessage); + await refreshFrame(); + return result; + } catch (error) { + showMessage(error, 3600); + await refreshFrame(); + return null; + } finally { + commandBusy = false; + } +} + +function openEditor(annotation) { + editorAnnotationId = annotation.id; + selectedAnnotationId = annotation.id; + editorPosition = null; + editorName.value = annotation.name; + editorType.value = annotation.regionType; + editorTypeLabel.textContent = regionTypeLabel(annotation.regionType); + editorDescription.value = annotation.description || ""; + render(); + window.setTimeout(() => editorName.focus(), 0); +} + +function closeEditor() { + editorAnnotationId = null; + editorPosition = null; + render(); +} + +function cancelLocalInteraction() { + if (!interaction) return false; + try { + root.releasePointerCapture(interaction.pointerId); + } catch { + // Pointer capture may already be released by the platform. + } + interaction = null; + render(); + return true; +} + +presetToolbar.addEventListener("click", (event) => { + const button = event.target.closest("[data-region-type]"); + if (!button || commandBusy || frame.mode !== "annotation") return; + const regionType = button.dataset.regionType; + const existing = frame.annotations.find((annotation) => annotation.regionType === regionType); + closeEditor(); + if (existing) { + activePresetType = null; + selectedAnnotationId = existing.id; + render(); + return; + } + + selectedAnnotationId = null; + activePresetType = activePresetType === regionType ? null : regionType; + render(); +}); + + +root.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || commandBusy) return; + if (event.target.closest(".annotation-editor, .region-preset-toolbar")) return; + + if (frame.mode === "window_select") { + if (frame.hoverWindow) { + activePresetType = null; + event.preventDefault(); + void runCommand("select_target_window", { + windowId: frame.hoverWindow.windowId, + }); + } + return; + } + if (frame.mode !== "annotation" || !frame.targetWindow) return; + + const annotationElement = event.target.closest("[data-annotation-id]"); + if (annotationElement) { + const annotation = frame.annotations.find( + (item) => item.id === annotationElement.dataset.annotationId, + ); + if (!annotation) return; + selectedAnnotationId = annotation.id; + const kind = event.target.closest("[data-resize-handle]") ? "resize" : "move"; + interaction = { + kind, + pointerId: event.pointerId, + annotationId: annotation.id, + start: physicalPoint(event), + originRect: { ...annotation.screenRect }, + currentRect: { ...annotation.screenRect }, + moved: false, + }; + root.setPointerCapture(event.pointerId); + event.preventDefault(); + render(); + return; + } + + if (!activePresetType) { + showMessage("请先从微信窗口右侧选择要标注的区域类型"); + return; + } + if (frame.annotations.some((annotation) => annotation.regionType === activePresetType)) { + activePresetType = null; + render(); + return; + } + + const point = physicalPoint(event); + const target = frame.targetWindow.rect; + if ( + point.x < target.x || + point.y < target.y || + point.x > target.x + target.width || + point.y > target.y + target.height + ) { + return; + } + selectedAnnotationId = null; + interaction = { + kind: "draw", + pointerId: event.pointerId, + regionType: activePresetType, + start: point, + currentRect: { x: point.x, y: point.y, width: 0, height: 0 }, + }; + root.setPointerCapture(event.pointerId); + event.preventDefault(); + render(); +}); + +root.addEventListener("pointermove", (event) => { + if (!interaction || interaction.pointerId !== event.pointerId || !frame.targetWindow) return; + const point = clampPointToTarget(physicalPoint(event)); + const target = frame.targetWindow.rect; + if (interaction.kind === "draw") { + interaction.currentRect = { + x: interaction.start.x, + y: interaction.start.y, + width: point.x - interaction.start.x, + height: point.y - interaction.start.y, + }; + } else if (interaction.kind === "move") { + const dx = point.x - interaction.start.x; + const dy = point.y - interaction.start.y; + interaction.moved ||= Math.abs(dx) >= 2 || Math.abs(dy) >= 2; + interaction.currentRect = { + ...interaction.originRect, + x: clamp( + interaction.originRect.x + dx, + target.x, + target.x + target.width - interaction.originRect.width, + ), + y: clamp( + interaction.originRect.y + dy, + target.y, + target.y + target.height - interaction.originRect.height, + ), + }; + } else { + const width = clamp(point.x - interaction.originRect.x, 6, target.x + target.width - interaction.originRect.x); + const height = clamp(point.y - interaction.originRect.y, 6, target.y + target.height - interaction.originRect.y); + interaction.moved ||= + Math.abs(width - interaction.originRect.width) >= 2 || + Math.abs(height - interaction.originRect.height) >= 2; + interaction.currentRect = { ...interaction.originRect, width, height }; + } + renderAnnotations(); +}); + +root.addEventListener("pointerup", async (event) => { + if (!interaction || interaction.pointerId !== event.pointerId) return; + const completed = interaction; + interaction = null; + try { + root.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture may already be released by the platform. + } + render(); + + if (completed.kind === "draw") { + const rect = normalizeRect(completed.currentRect); + if (rect.width < 6 || rect.height < 6 || !frame.targetWindow || !completed.regionType) return; + const preset = REGION_PRESET_BY_TYPE.get(completed.regionType); + const created = await runCommand( + "add_annotation", + { + input: { + windowId: frame.targetWindow.windowId, + name: preset?.label || completed.regionType, + regionType: completed.regionType, + screenRect: rect, + updatedAt: nowIso(), + }, + }, + "标注已自动保存", + ); + if (created) { + selectedAnnotationId = created.id; + activePresetType = null; + render(); + } + return; + } + + if (completed.moved) { + await runCommand( + "update_annotation_geometry", + { + input: { + annotationId: completed.annotationId, + screenRect: completed.currentRect, + updatedAt: nowIso(), + }, + }, + "标注已自动保存", + ); + return; + } + + const clickedAt = performance.now(); + const isSecondClick = + lastAnnotationClick?.annotationId === completed.annotationId && + clickedAt - lastAnnotationClick.at <= 280; + lastAnnotationClick = { annotationId: completed.annotationId, at: clickedAt }; + if (isSecondClick) { + const annotation = frame.annotations.find((item) => item.id === completed.annotationId); + if (annotation) openEditor(annotation); + } +}); + +root.addEventListener("pointercancel", cancelLocalInteraction); +window.addEventListener("blur", cancelLocalInteraction); +window.addEventListener("resize", render); + +editor.querySelector('[data-action="close"]').addEventListener("click", closeEditor); +editor.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!editorAnnotationId) return; + const updated = await runCommand( + "update_annotation", + { + input: { + annotationId: editorAnnotationId, + name: editorName.value, + regionType: editorType.value, + description: editorDescription.value, + updatedAt: nowIso(), + }, + }, + "标注已自动保存", + ); + if (updated) closeEditor(); +}); + +window.addEventListener("keydown", async (event) => { + if (event.key === "Escape") { + event.preventDefault(); + if (cancelLocalInteraction()) return; + if (editorAnnotationId) { + closeEditor(); + return; + } + await runCommand("hide_overlay", {}); + return; + } + + if ( + (event.key === "Delete" || event.key === "Backspace") && + selectedAnnotationId && + !event.target.closest("input, textarea, select") + ) { + event.preventDefault(); + const deleted = await runCommand( + "delete_annotation", + { + input: { + annotationId: selectedAnnotationId, + updatedAt: nowIso(), + }, + }, + "标注已自动保存", + ); + if (deleted !== null) selectedAnnotationId = null; + } +}); + +void refreshFrame(); +window.setInterval(refreshFrame, 80); diff --git a/src/pages/AnnotationPage.jsx b/src/pages/AnnotationPage.jsx index a558693..95f1a78 100644 --- a/src/pages/AnnotationPage.jsx +++ b/src/pages/AnnotationPage.jsx @@ -3,6 +3,7 @@ import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { Check, MousePointer2, Trash2, X } from "lucide-react"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; +import { closeWindow } from "../utils/navigation"; const regionTypes = [ { value: "contact_list", label: "联系人区域" }, @@ -91,6 +92,143 @@ function formatCaptureTiming(capture, frontendMs) { ].join(" · "); } +function toUint8Array(bytes) { + if (Array.isArray(bytes)) return new Uint8Array(bytes); + if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes); + if (bytes?.data) return new Uint8Array(bytes.data); + if (ArrayBuffer.isView(bytes)) return new Uint8Array(bytes); + throw new Error("unsupported screenshot byte payload"); +} + +function loadImageFromUrl(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("image decode failed")); + image.src = url; + }); +} + +function computeContainViewport(canvasWidth, canvasHeight, imageWidth, imageHeight) { + if (!canvasWidth || !canvasHeight || !imageWidth || !imageHeight) return null; + + const scale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight); + const width = imageWidth * scale; + const height = imageHeight * scale; + + return { + left: (canvasWidth - width) / 2, + top: (canvasHeight - height) / 2, + width, + height, + scale, + }; +} + +function drawCanvas(ctx, viewport, image, regions, selectedId, tempBox, statusText) { + const canvas = ctx.canvas; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#050505"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + if (image && viewport) { + ctx.drawImage(image, viewport.left, viewport.top, viewport.width, viewport.height); + + regions.forEach((region, index) => { + const [x1, y1, x2, y2] = region.bbox_image; + const selected = region.id === selectedId; + const left = viewport.left + x1 * viewport.scale; + const top = viewport.top + y1 * viewport.scale; + const width = (x2 - x1) * viewport.scale; + const height = (y2 - y1) * viewport.scale; + const stroke = selected ? "#22c55e" : "#2d8cff"; + const fill = selected ? "rgba(34,197,94,0.16)" : "rgba(45,140,255,0.12)"; + const label = region.name || `区域 ${index + 1}`; + + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = selected ? 4 : 3; + ctx.setLineDash([]); + ctx.fillRect(left, top, width, height); + ctx.strokeRect(left, top, width, height); + + ctx.font = "700 14px sans-serif"; + const labelWidth = Math.min(ctx.measureText(label).width + 16, Math.max(width, 70)); + const labelHeight = 24; + const labelTop = Math.max(viewport.top, top - labelHeight); + ctx.fillStyle = selected ? "#22c55e" : "#2d8cff"; + ctx.fillRect(left, labelTop, labelWidth, labelHeight); + ctx.fillStyle = "#ffffff"; + ctx.fillText(label, left + 8, labelTop + 17, labelWidth - 12); + ctx.restore(); + }); + + if (tempBox) { + const [x1, y1, x2, y2] = tempBox; + ctx.save(); + ctx.strokeStyle = "#facc15"; + ctx.lineWidth = 4; + ctx.setLineDash([10, 8]); + ctx.strokeRect( + viewport.left + x1 * viewport.scale, + viewport.top + y1 * viewport.scale, + (x2 - x1) * viewport.scale, + (y2 - y1) * viewport.scale, + ); + ctx.restore(); + } + + return; + } + + ctx.save(); + ctx.fillStyle = "#ffffff"; + ctx.font = "700 18px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(statusText || "等待截图数据", canvas.width / 2, canvas.height / 2, Math.max(80, canvas.width - 64)); + ctx.restore(); +} + +function getCanvasImagePoint(event, canvas, viewport, capture, clampOutside = false) { + if (!canvas || !viewport || !capture.screenshotWidth || !capture.screenshotHeight) return null; + + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + let canvasX = (event.clientX - rect.left) * scaleX; + let canvasY = (event.clientY - rect.top) * scaleY; + const viewportRight = viewport.left + viewport.width; + const viewportBottom = viewport.top + viewport.height; + + if ( + canvasX < viewport.left || + canvasX > viewportRight || + canvasY < viewport.top || + canvasY > viewportBottom + ) { + if (!clampOutside) return null; + canvasX = clamp(canvasX, viewport.left, viewportRight); + canvasY = clamp(canvasY, viewport.top, viewportBottom); + } + + return { + x: clamp((canvasX - viewport.left) / viewport.scale, 0, capture.screenshotWidth), + y: clamp((canvasY - viewport.top) / viewport.scale, 0, capture.screenshotHeight), + }; +} + +function getCurrentWindowLabel() { + try { + return window.__TAURI_INTERNALS__ ? getCurrentWindow().label : "browser"; + } catch { + return "unknown"; + } +} + export default function AnnotationPage() { const [capture, setCapture] = useState(emptyCapture); const [regions, setRegions] = useState([]); @@ -99,10 +237,14 @@ export default function AnnotationPage() { const [drawEnd, setDrawEnd] = useState(null); const [status, setStatus] = useState("正在准备截图..."); const [saving, setSaving] = useState(false); - const imageRef = useRef(null); + const [decodedImage, setDecodedImage] = useState(null); + const [imageViewport, setImageViewport] = useState(null); + const [imageLoadError, setImageLoadError] = useState(""); + const [canvasSize, setCanvasSize] = useState({ width: 1, height: 1 }); + const canvasRef = useRef(null); + const canvasShellRef = useRef(null); const selectedRegion = regions.find((region) => region.id === selectedId) || null; - const screenshotSrc = capture.screenshotPath ? convertFileSrc(capture.screenshotPath) : ""; const tempBox = drawStart && drawEnd ? normalizeBox(drawStart, drawEnd) : null; const typeLabelMap = useMemo( @@ -110,6 +252,58 @@ export default function AnnotationPage() { [], ); + useEffect(() => { + if (!capture.screenshotPath) { + setDecodedImage(null); + setImageLoadError(""); + return undefined; + } + + let cancelled = false; + let objectUrl = ""; + + async function loadScreenshot() { + setDecodedImage(null); + setImageLoadError(""); + + try { + const bytes = await invoke("read_screenshot_bytes", { path: capture.screenshotPath }); + if (cancelled) return; + const blob = new Blob([toUint8Array(bytes)], { type: "image/jpeg" }); + objectUrl = URL.createObjectURL(blob); + const image = await loadImageFromUrl(objectUrl); + if (!cancelled) { + setDecodedImage(image); + setStatus("拖拽截图区域创建矩形框"); + } + return; + } catch (byteError) { + if (cancelled) return; + try { + const image = await loadImageFromUrl(convertFileSrc(capture.screenshotPath)); + if (!cancelled) { + setDecodedImage(image); + setStatus("拖拽截图区域创建矩形框"); + } + } catch (fileError) { + if (!cancelled) { + const message = `${String(byteError)};${String(fileError)}`; + setDecodedImage(null); + setImageLoadError(message); + setStatus(`截图图片加载失败:${capture.screenshotPath}`); + } + } + } + } + + loadScreenshot(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [capture.screenshotPath]); + useEffect(() => { let cancelled = false; @@ -120,7 +314,7 @@ export default function AnnotationPage() { if (!cancelled) { setCapture(nextCapture); setStatus("拖拽截图区域创建矩形框"); - if (window.__TAURI_INTERNALS__) { + if (window.__TAURI_INTERNALS__ && getCurrentWindowLabel() === "main") { await getCurrentWindow().setFullscreen(true); } } @@ -186,7 +380,9 @@ export default function AnnotationPage() { sessionStorage.setItem("pendingAnnotationCapture", JSON.stringify(nextCapture)); setCapture(nextCapture); setStatus(formatCaptureTiming(nextCapture, performance.now() - startedAt)); - await getCurrentWindow().setFullscreen(true); + if (getCurrentWindowLabel() === "main") { + await getCurrentWindow().setFullscreen(true); + } } } @@ -213,22 +409,62 @@ export default function AnnotationPage() { loadSaved().catch(() => {}); }, []); - function getImagePoint(event) { - const rect = imageRef.current?.getBoundingClientRect(); - if (!rect || !capture.screenshotWidth || !capture.screenshotHeight) return null; + useEffect(() => { + const canvas = canvasRef.current; + const shell = canvasShellRef.current; + if (!canvas || !shell) return undefined; - const displayX = clamp(event.clientX - rect.left, 0, rect.width); - const displayY = clamp(event.clientY - rect.top, 0, rect.height); - - return { - x: (displayX * capture.screenshotWidth) / rect.width, - y: (displayY * capture.screenshotHeight) / rect.height, + const updateCanvasSize = () => { + const rect = shell.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + const width = Math.max(1, Math.round(rect.width * ratio)); + const height = Math.max(1, Math.round(rect.height * ratio)); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + setCanvasSize((current) => (current.width === width && current.height === height ? current : { width, height })); }; - } + + updateCanvasSize(); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(updateCanvasSize); + observer.observe(shell); + return () => observer.disconnect(); + } + + window.addEventListener("resize", updateCanvasSize); + return () => window.removeEventListener("resize", updateCanvasSize); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + const viewport = decodedImage + ? computeContainViewport(canvas.width, canvas.height, decodedImage.naturalWidth, decodedImage.naturalHeight) + : null; + setImageViewport((current) => { + if (!current && !viewport) return current; + if ( + current && + viewport && + current.left === viewport.left && + current.top === viewport.top && + current.width === viewport.width && + current.height === viewport.height && + current.scale === viewport.scale + ) { + return current; + } + return viewport; + }); + drawCanvas(ctx, viewport, decodedImage, regions, selectedId, tempBox, imageLoadError || status); + }, [decodedImage, regions, selectedId, tempBox, status, imageLoadError, canvasSize]); function handlePointerDown(event) { if (event.button !== 0) return; - const point = getImagePoint(event); + const point = getCanvasImagePoint(event, canvasRef.current, imageViewport, capture); if (!point) return; event.currentTarget.setPointerCapture(event.pointerId); @@ -239,18 +475,23 @@ export default function AnnotationPage() { function handlePointerMove(event) { if (!drawStart) return; - const point = getImagePoint(event); + const point = getCanvasImagePoint(event, canvasRef.current, imageViewport, capture, true); if (point) setDrawEnd(point); } function handlePointerUp(event) { - if (!drawStart || !drawEnd) return; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } - const box = normalizeBox(drawStart, drawEnd); + const finalPoint = getCanvasImagePoint(event, canvasRef.current, imageViewport, capture, true) || drawEnd; + if (!drawStart || !finalPoint) { + setDrawStart(null); + setDrawEnd(null); + return; + } + + const box = normalizeBox(drawStart, finalPoint); const width = box[2] - box[0]; const height = box[3] - box[1]; setDrawStart(null); @@ -292,7 +533,7 @@ export default function AnnotationPage() { if (window.__TAURI_INTERNALS__) { const currentWindow = getCurrentWindow(); if (currentWindow.label !== "main") { - await currentWindow.close(); + await closeWindow(); return; } @@ -344,6 +585,7 @@ export default function AnnotationPage() {

微信 RPA 坐标配置

{status}

+ {imageLoadError ?

{imageLoadError}

: null}
@@ -445,79 +687,16 @@ export default function AnnotationPage() {
-
-
- {screenshotSrc ? ( - <> - 当前屏幕截图 setStatus("拖拽截图区域创建矩形框")} - onError={() => setStatus(`截图图片加载失败:${capture.screenshotPath}`)} - /> - - - {regions.map((region) => { - const [x1, y1, x2, y2] = region.bbox_image; - const selected = region.id === selectedId; - return ( - { event.stopPropagation(); setSelectedId(region.id); }}> - - - {region.name} - - - ); - })} - {tempBox ? ( - - ) : null} - - - ) : ( -
- {status} -
- )} +
+
+
diff --git a/src/pages/ClonePage.jsx b/src/pages/ClonePage.jsx index d06dad0..deb9f6d 100644 --- a/src/pages/ClonePage.jsx +++ b/src/pages/ClonePage.jsx @@ -1,207 +1,234 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { Crosshair, Power, PowerOff, Settings } from "lucide-react"; -import StatusPill from "../components/StatusPill"; +import { Crosshair, Power } from "lucide-react"; +import NodeSphere from "../components/NodeSphere"; import Terminal from "../components/Terminal"; import { openWindow } from "../utils/navigation"; +const engineStatusContent = { + idle: { title: "微信引擎未运行", description: "启动后等待并处理本地微信消息。" }, + running: { title: "微信引擎运行中", description: "已处理 26 条消息 · 当前队列 0" }, + error: { title: "微信引擎运行异常", description: "启动失败,请查看运行日志并检查本地配置。" }, +}; + +const requiredAnnotationTypes = ["contact_list", "chat_content", "input_box", "unread_message"]; +const initialChecks = [ + { id: "model", label: "大模型连接情况", detail: "配置完整,连接延迟 324ms", status: "waiting", passed: true }, + { id: "annotation", label: "微信区域标注情况", detail: "联系人、聊天内容、输入框和发送区域完整", status: "waiting", passed: true }, + { id: "window", label: "微信窗口连接情况", detail: "已找到 WeChat 主窗口", status: "waiting", passed: true }, +]; + +function hasCompleteAnnotation(annotation) { + const regionTypes = new Set(annotation?.regions?.map((region) => region.type) || []); + return requiredAnnotationTypes.every((regionType) => regionTypes.has(regionType)); +} + +const wait = (duration) => new Promise((resolve) => setTimeout(resolve, duration)); + export default function ClonePage() { - const [engineEnabled, setEngineEnabled] = useState(false); + const [engineStatus, setEngineStatus] = useState("idle"); const [engineBusy, setEngineBusy] = useState(false); - const [agentLogs, setAgentLogs] = useState([]); - const [annotating, setAnnotating] = useState(false); - const [sourcePickerOpen, setSourcePickerOpen] = useState(false); - const [captureSources, setCaptureSources] = useState([]); - const [sourceStatus, setSourceStatus] = useState(""); + const [annotationBusy, setAnnotationBusy] = useState(false); + const [annotationComplete, setAnnotationComplete] = useState(false); + const [agentLogs, setAgentLogs] = useState([{ level: "info", message: "[12:30:01] 等待启动" }]); + const [checkOpen, setCheckOpen] = useState(false); + const [checks, setChecks] = useState(initialChecks); + const [checkPhase, setCheckPhase] = useState("idle"); + const [autoSendPaused, setAutoSendPaused] = useState(false); + const engineRunning = engineStatus === "running"; + const statusContent = engineStatusContent[engineStatus]; useEffect(() => { if (!window.__TAURI_INTERNALS__) return undefined; - let unlisten; let cancelled = false; listen("engine-state-changed", (event) => { - setEngineEnabled(Boolean(event.payload?.enabled)); + const running = event.payload?.enabled === true; + setEngineStatus(running ? "running" : "idle"); + if (!running) setAutoSendPaused(false); setEngineBusy(false); - setAgentLogs((currentLogs) => [ - ...currentLogs.slice(-300), - { level: "warning", message: "工作台已关闭,引擎状态已同步" }, - ]); - }).then((cleanup) => { - if (cancelled) { - cleanup(); - return; - } - unlisten = cleanup; - }); - - return () => { - cancelled = true; - if (unlisten) unlisten(); - }; + }).then((cleanup) => { if (cancelled) cleanup(); else unlisten = cleanup; }); + return () => { cancelled = true; if (unlisten) unlisten(); }; }, []); - async function startAnnotation() { - if (!window.__TAURI_INTERNALS__) { - window.location.hash = "/annotate"; - return; + useEffect(() => { + if (!window.__TAURI_INTERNALS__) return undefined; + let unlisten; + let cancelled = false; + async function observeAnnotationCompletion() { + const cleanup = await listen("annotation-completion-changed", (event) => setAnnotationComplete(event.payload === true)); + if (cancelled) { cleanup(); return; } + unlisten = cleanup; + const annotation = await invoke("load_regions").catch(() => null); + if (!cancelled) setAnnotationComplete(hasCompleteAnnotation(annotation)); } - - setSourcePickerOpen(true); - setSourceStatus("正在读取桌面和窗口列表..."); - try { - const sources = await invoke("list_capture_sources"); - setCaptureSources(sources); - setSourceStatus(sources.length ? "请选择一个桌面或应用窗口" : "未找到可用来源"); - } catch (error) { - setCaptureSources([]); - setSourceStatus(`读取来源失败:${String(error)}`); - } - } - - function beginAnnotationWithSource(source) { - setAnnotating(true); - sessionStorage.removeItem("pendingAnnotationCapture"); - localStorage.removeItem("annotationCaptureResult"); - localStorage.removeItem("annotationCaptureError"); - localStorage.setItem("annotationCaptureSource", JSON.stringify(source)); - localStorage.setItem("annotationCaptureStatus", "pending"); - setSourcePickerOpen(false); - - if (window.__TAURI_INTERNALS__) { - invoke("capture_source", { sourceId: source.id }) - .then((capture) => { - localStorage.setItem("annotationCaptureResult", JSON.stringify(capture)); - localStorage.setItem("annotationCaptureStatus", "done"); - }) - .catch((error) => { - localStorage.setItem("annotationCaptureError", String(error)); - localStorage.setItem("annotationCaptureStatus", "error"); - }); - - openWindow("/annotate").finally(() => setAnnotating(false)); - return; - } - - window.location.hash = "/annotate"; - setAnnotating(false); - } + void observeAnnotationCompletion(); + return () => { cancelled = true; if (unlisten) unlisten(); }; + }, []); async function toggleAgent() { - if (engineBusy) return; - + if (engineBusy) return false; if (!window.__TAURI_INTERNALS__) { - setEngineEnabled((enabled) => !enabled); - return; + setEngineStatus(engineRunning ? "idle" : "running"); + setAutoSendPaused(false); + setAgentLogs((current) => [...current, { level: "info", message: engineRunning ? "[12:38:30] 引擎已停止" : "[12:38:21] 引擎启动成功" }]); + return true; } - setEngineBusy(true); try { - if (engineEnabled) { + if (engineRunning) { await invoke("stop_agent"); - await invoke("stop_vision_stream").catch((error) => { - setAgentLogs((currentLogs) => [ - ...currentLogs, - { level: "warning", message: `停止视觉流失败:${String(error)}` }, - ]); - }); - setEngineEnabled(false); - setAgentLogs((currentLogs) => [ - ...currentLogs.slice(-300), - { level: "warning", message: "引擎已停用,暂未读取 Rust 日志" }, - ]); + await invoke("stop_vision_stream"); + setEngineStatus("idle"); + setAutoSendPaused(false); + setAgentLogs((current) => [...current.slice(-300), { level: "warning", message: "引擎已停用,暂未读取 Rust 日志" }]); } else { await invoke("start_vision_stream"); - await invoke("start_agent").catch((error) => { - setAgentLogs((currentLogs) => [ - ...currentLogs.slice(-300), - { level: "warning", message: `agent 启动失败,预览已继续启动:${String(error)}` }, - ]); - }); - setEngineEnabled(true); - setAgentLogs((currentLogs) => [ - ...currentLogs.slice(-300), - { level: "info", message: "引擎已启动,暂未读取 Rust 日志" }, - ]); - await openWindow("/window/engine-workbench"); + try { await invoke("start_agent"); } catch (error) { await invoke("stop_vision_stream").catch(() => {}); throw new Error(`agent 启动失败:${String(error)}`); } + setEngineStatus("running"); + setAutoSendPaused(false); + setAgentLogs((current) => [...current.slice(-300), { level: "info", message: "引擎已启动,暂未读取 Rust 日志" }]); } + return true; } catch (error) { - setAgentLogs((currentLogs) => [ - ...currentLogs, - { level: "error", message: String(error) }, - ]); + setEngineStatus("error"); + setAgentLogs((current) => [...current, { level: "error", message: String(error) }]); + return false; } finally { setEngineBusy(false); } } + function toggleAutoSend() { + setAutoSendPaused((paused) => { + const nextPaused = !paused; + setAgentLogs((current) => [ + ...current.slice(-300), + { + level: nextPaused ? "warning" : "info", + message: nextPaused ? "已暂停自动发送" : "已恢复自动发送", + }, + ]); + return nextPaused; + }); + } + + function requestToggle() { + if (engineRunning) { void toggleAgent(); return; } + setChecks(initialChecks); + setCheckPhase("idle"); + setCheckOpen(true); + setTimeout(runStartupChecks, 220); + } + + async function runStartupChecks() { + setCheckPhase("checking"); + setChecks(initialChecks); + for (let index = 0; index < initialChecks.length; index += 1) { + setChecks((items) => items.map((item, itemIndex) => ({ ...item, status: itemIndex === index ? "checking" : itemIndex < index ? "passed" : "waiting" }))); + await wait(950); + const check = initialChecks[index]; + if (!check.passed) { + setChecks((items) => items.map((item, itemIndex) => ({ ...item, status: itemIndex === index ? "failed" : item.status }))); + setCheckPhase("failed"); + await wait(1600); + setCheckOpen(false); + return; + } + setChecks((items) => items.map((item, itemIndex) => ({ ...item, status: itemIndex === index ? "passed" : item.status }))); + } + setCheckPhase("starting"); + const started = await toggleAgent(); + if (started) { setCheckPhase("passed"); await wait(900); setCheckOpen(false); } else { setCheckPhase("failed-start"); await wait(1600); setCheckOpen(false); } + } + + async function startAnnotation() { + if (annotationBusy) return; + if (!window.__TAURI_INTERNALS__) { setAgentLogs((current) => [...current.slice(-300), { level: "warning", message: "窗口选择标注请在 Tauri Windows 应用中使用" }]); return; } + setAnnotationBusy(true); + try { await invoke("enter_window_select_mode"); setAgentLogs((current) => [...current.slice(-300), { level: "info", message: "已进入窗口选择模式,请点击需要标注的窗口" }]); } + catch (error) { setAgentLogs((current) => [...current.slice(-300), { level: "error", message: String(error) }]); } + finally { setAnnotationBusy(false); } + } + return (
-
- -
-
- -
-
-
-

屏幕区域标注

-

截取当前屏幕,配置联系人、聊天内容、输入框和发送按钮坐标。

+
+ +

{statusContent.title}

{statusContent.description}

+ {engineRunning ? ( +
+ +
- -
+ ) : ( + + )}
- {sourcePickerOpen ? ( -
-
-
-
-

选择标注来源 Demo

-

可以选择某个桌面,或某个可见应用窗口。

-
- -
-
{sourceStatus}
-
- {captureSources.map((source) => ( - - ))} -
-
-
- ) : null} - setAgentLogs([])} /> + openWindow("/window/engine-logs")} onClear={() => setAgentLogs([])} /> + {checkOpen ? : null}
); } + +function StartupCheckModal({ checks, phase }) { + const visibleChecks = checks.filter((item) => item.status !== "waiting"); + const progress = phase === "starting" || phase === "passed" + ? 100 + : Math.round((visibleChecks.length / checks.length) * 100); + + return ( +
+
+
+
+
+ +
+

+ 正在检测引擎启动环境 +

+ +
+ {visibleChecks.map((item) => ( +
+ {item.label} +
+ ))} +
+ + {phase === "starting" ?

全部检查成功,正在启动引擎...

: null} + {phase === "failed" || phase === "failed-start" ?

检查失败,启动流程已终止。

: null} +
+
+
+ ); +} diff --git a/src/pages/DistillPage.jsx b/src/pages/DistillPage.jsx index 47fa372..610f822 100644 --- a/src/pages/DistillPage.jsx +++ b/src/pages/DistillPage.jsx @@ -1,48 +1,180 @@ +import { useMemo, useState } from "react"; +import { BookOpen, Search, X } from "lucide-react"; +import { SimpleSelect } from "../components/ui/select"; import { employeeItems } from "../data/mockData"; import { openWindow } from "../utils/navigation"; export default function DistillPage() { + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("全部状态"); + const [taskVisible, setTaskVisible] = useState(true); + const [tutorial, setTutorial] = useState(false); + const filtered = useMemo( + () => + employeeItems.filter( + (item) => + (status === "全部状态" || (status === "已启用") === item.enabled) && + `${item.name} ${item.domain} ${item.traits}` + .toLowerCase() + .includes(query.toLowerCase()), + ), + [query, status], + ); + return ( -
-
-

把销售冠军蒸馏成员工 skill

-

导入聊天记录、话术和案例,沉淀可授权调用的员工能力。

-
- - +
+
+

+ 把优秀话术沉淀成员工能力 +

+

+ 导入聊天记录、话术和案例,形成可评估、可授权的员工。 +

+
+ +
-
-
-
-

销售冠军 Aileen 话术蒸馏

-

- 成功 248 条 - · - 失败 7 条 -

-
- 72% + {taskVisible ? ( +
+
当前任务
+
openWindow("/window/distill-start?step=3")} + > +
+
+

+ 销售冠军 Aileen 话术蒸馏 +

+

+ 能力提取 · 剩余约 3 分钟 +

+
+ 72% +
+
+
+
+
+ + 成功 248 + · + 失败 7 + +
+ + +
+
+
-
-
+ ) : null} +
+
+
+ + setQuery(event.target.value)} + /> +
- {employeeItems.map((item) => ( -
openWindow("/window/employee-detail")}> -
-

{item.name}

-
- {item.version} - · - {item.enabled ? "已启用" : "已关闭"} + {filtered.map((item, index) => ( +
+ openWindow(`/window/employee-detail?id=employee-${index + 1}`) + } + > +
+
+

{item.name}

+

{item.domain}

+
+
+
{item.version}
+
+ {item.enabled ? "● 已启用" : "○ 已关闭"} +
-

{item.traits}

+

+ {item.traits} +

+
+ + 评分 {96 - index * 4} · 本周调用 {28 - index * 6} + + +
))}
+ {tutorial ? ( +
+
+
+

员工蒸馏教程

+ +
+
    +
  1. 1. 导入已获授权的聊天、话术或案例。
  2. +
  3. 2. 检查并确认联系人、手机号和订单号脱敏。
  4. +
  5. 3. 等待能力提取,处理失败样本。
  6. +
  7. 4. 通过效果评估后配置授权并发布员工。
  8. +
+ +
+
+ ) : null}
); } diff --git a/src/pages/KnowledgePage.jsx b/src/pages/KnowledgePage.jsx index 6d84e46..ef8e57c 100644 --- a/src/pages/KnowledgePage.jsx +++ b/src/pages/KnowledgePage.jsx @@ -1,31 +1,39 @@ -import { knowledgeItems } from "../data/mockData"; +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; import DraggableFab from "../components/DraggableFab"; -import Segmented from "../components/Segmented"; import StatusPill from "../components/StatusPill"; import { openWindow } from "../utils/navigation"; -export default function KnowledgePage({ active, setActive }) { - const types = ["全部", "视频", "图片", "文件", "案例", "其他"]; - const filtered = active === "全部" ? knowledgeItems : knowledgeItems.filter((item) => item.type === active); +const items = [ + { id: "kb-sales-champion", title: "销售冠军高意向客户跟进案例", type: "案例", tags: ["私域成交", "高意向"], updated: "06-08", refs: 128, files: 3, version: "v2.4", status: "已生效" }, + { id: "kb-refund", title: "售后常见问题与退款边界", type: "文件", tags: ["售后", "退款"], updated: "06-05", refs: 82, files: 4, version: "v1.7", status: "已生效" }, + { id: "kb-training", title: "产品培训视频", type: "视频", tags: ["产品", "培训"], updated: "06-20", refs: 26, files: 2, version: "v1.0", status: "已生效" }, + { id: "kb-boundary", title: "售后边界说明", type: "文件", tags: ["售后", "边界"], updated: "06-18", refs: 12, files: 1, version: "v1.2", status: "已生效" }, + { id: "kb-images", title: "企业微信客户分层话术图片包", type: "图片", tags: ["客户分层", "话术"], updated: "05-30", refs: 41, files: 12, version: "v1.1", status: "已生效" }, + { id: "kb-live", title: "直播间私域导流短视频脚本", type: "视频", tags: ["直播", "私域"], updated: "05-24", refs: 35, files: 3, version: "v0.9", status: "已停用" }, +]; + + +export default function KnowledgePage() { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => items.filter((item) => { + const keyword = `${item.title} ${item.tags.join(" ")}`.toLowerCase(); + return keyword.includes(query.toLowerCase()); + }), [query]); + return (
- -
+
setQuery(event.target.value)} />
+ +
{filtered.map((item) => ( -
openWindow("/window/knowledge-detail")}> -
-
-

{item.title}

-
-

更新 {item.updated} · 引用 {item.refs} 次

-
-
- {item.version} - -
+
openWindow(`/window/knowledge-detail?id=${item.id}`)}> +

{item.title}

{item.type}{item.tags.map((tag) => {tag})}
+
{item.version} · 更新 {item.updated} · 引用 {item.refs}文件 {item.files} 个
))} + {!filtered.length ?

暂无匹配知识

调整搜索关键词后重试。

: null}
openWindow("/window/knowledge-create")} ariaLabel="新增知识库" />
diff --git a/src/pages/SkillsPage.jsx b/src/pages/SkillsPage.jsx index f9e5c48..2e2a274 100644 --- a/src/pages/SkillsPage.jsx +++ b/src/pages/SkillsPage.jsx @@ -1,37 +1,44 @@ -import { skillItems, tagToneClasses } from "../data/mockData"; +import { useMemo, useState } from "react"; +import { RefreshCw, Search } from "lucide-react"; import DraggableFab from "../components/DraggableFab"; import Segmented from "../components/Segmented"; +import { SimpleSelect } from "../components/ui/select"; +import { tagToneClasses } from "../data/mockData"; import { openWindow } from "../utils/navigation"; +const initialSkills = [ + { id: "intent", name: "客户意图识别", type: "内置", enabled: true, version: "v2.1", tags: ["线索分级", "异议判断", "下一步建议"], meta: "绑定知识 3 · 最近调用 16 次", tested: true }, + { id: "quote", name: "报价策略生成", type: "自定义", enabled: true, version: "v1.3", tags: ["利润保护", "阶梯报价", "优惠边界"], meta: "测试通过 · 修改于 2 天前", tested: true }, + { id: "after-sale", name: "售后安抚助手", type: "内置", enabled: false, version: "v1.0", tags: ["情绪识别", "补偿建议", "工单摘要"], meta: "尚未完成沙箱测试", tested: false }, + { id: "moments", name: "朋友圈内容策划", type: "自定义", enabled: true, version: "v1.2", tags: ["素材改写", "发布时间", "人设统一"], meta: "绑定知识 2 · 最近调用 8 次", tested: true }, +]; + export default function SkillsPage({ active, setActive }) { + const [skills, setSkills] = useState(initialSkills); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("全部状态"); + const [refreshing, setRefreshing] = useState(false); const types = ["全部", "内置", "自定义"]; - const filtered = active === "全部" ? skillItems : skillItems.filter((item) => item.type === active); + const filtered = useMemo(() => skills.filter((skill) => (active === "全部" || skill.type === active) && (status === "全部状态" || (status === "已启用") === skill.enabled) && `${skill.name} ${skill.tags.join(" ")}`.toLowerCase().includes(query.toLowerCase())), [active, query, skills, status]); + + function toggle(id) { + setSkills((items) => items.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item)); + } return ( -
- +
+
setQuery(event.target.value)} />
{filtered.map((skill) => ( -
-
-
-
-

{skill.name}

-
- {skill.type} - · - {skill.enabled ? "已启用" : "已关闭"} -
-
-
-
-
- {skill.tags.map((tag, index) => {tag})} -
+
openWindow(`/window/skill-detail?id=${skill.id}`)}> +

{skill.name}

{skill.enabled ? "● 已启用" : "○ 已关闭"}
+
{skill.tags.map((tag, index) => {tag})}
+
{skill.meta}
))} + {!filtered.length ?
暂无匹配智能体

调整筛选条件后重试。

: null}
- openWindow("/window/skill-create")} ariaLabel="新增 skill" /> + openWindow("/window/skill-create")} ariaLabel="新增智能体" />
); } diff --git a/src/utils/documentText.js b/src/utils/documentText.js new file mode 100644 index 0000000..99da2de --- /dev/null +++ b/src/utils/documentText.js @@ -0,0 +1,73 @@ +import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; + +function fileExtension(fileName) { + return fileName.split(".").pop()?.toLowerCase() || ""; +} + +function normalizeText(text) { + return text + .replace(/\r\n?/g, "\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +async function extractDocxText(file) { + const mammoth = await import("mammoth"); + const result = await mammoth.convertToPlainText({ arrayBuffer: await file.arrayBuffer() }); + return normalizeText(result.value); +} + +async function extractPdfText(file) { + const pdfjs = await import("pdfjs-dist"); + pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; + const document = await pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()) }).promise; + const pages = []; + + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const content = await page.getTextContent(); + pages.push(content.items.map((item) => item.str || "").join(" ")); + } + + return normalizeText(pages.join("\n\n")); +} + +async function extractPptxText(file) { + const { default: JSZip } = await import("jszip"); + const zip = await JSZip.loadAsync(await file.arrayBuffer()); + const slideNames = Object.keys(zip.files) + .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name)) + .sort((left, right) => Number(left.match(/slide(\d+)/)?.[1]) - Number(right.match(/slide(\d+)/)?.[1])); + + if (!slideNames.length) { + throw new Error("未找到可读取的 PPTX 幻灯片内容"); + } + + const slides = await Promise.all(slideNames.map(async (name) => { + const xml = await zip.file(name).async("string"); + const document = new DOMParser().parseFromString(xml, "application/xml"); + return Array.from(document.getElementsByTagNameNS("*", "t"), (node) => node.textContent || "").join(" "); + })); + + return normalizeText(slides.join("\n\n")); +} + +export async function extractDocumentText(file) { + const extension = fileExtension(file.name); + let text; + if (extension === "txt") text = normalizeText(await file.text()); + else if (extension === "docx") text = await extractDocxText(file); + else if (extension === "pdf") text = await extractPdfText(file); + else if (extension === "pptx" || extension === "ppt") { + try { + text = await extractPptxText(file); + } catch (error) { + if (extension === "ppt") throw new Error("旧版 PPT 无法直接解析,请另存为 PPTX 后重试"); + throw error; + } + } else text = normalizeText(await file.text()); + + if (!text) throw new Error("文档中未提取到可预览文本"); + return text; +} diff --git a/src/utils/navigation.js b/src/utils/navigation.js index b3bfb55..3bec2c1 100644 --- a/src/utils/navigation.js +++ b/src/utils/navigation.js @@ -7,25 +7,57 @@ export function go(path) { export async function openWindow(path) { if (!window.__TAURI_INTERNALS__) { - go(path); - return; + const routeName = path + .split("?")[0] + .replace(/^\/+/, "") + .replace(/[^a-zA-Z0-9_-]/g, "-"); + const width = 1080; + const height = 800; + const left = Math.max(0, Math.round((window.screen.availWidth - width) / 2)); + const top = Math.max(0, Math.round((window.screen.availHeight - height) / 2)); + const popup = window.open( + `${window.location.origin}${window.location.pathname}#${path}`, + `popup-${routeName}`, + `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`, + ); + if (!popup) { + console.error("Failed to open popup window: browser blocked the popup"); + return false; + } + return true; } try { await invoke("open_popup_window", { route: path }); + return true; } catch (error) { console.error("Failed to open popup window", error); - go(path); + return false; } } export async function closeWindow() { if (!window.__TAURI_INTERNALS__) { - go("/"); + if (window.opener) window.close(); + else go("/"); return; } - await getCurrentWindow().close(); + try { + await invoke("close_current_window"); + } catch (error) { + console.error("Failed to close current window", error); + await getCurrentWindow().close(); + } +} + +export async function exitApplication() { + if (!window.__TAURI_INTERNALS__) { + window.close(); + return; + } + + await invoke("exit_application"); } export async function startWindowDrag() { diff --git a/src/windows/DistillWindows.jsx b/src/windows/DistillWindows.jsx new file mode 100644 index 0000000..eb68fdb --- /dev/null +++ b/src/windows/DistillWindows.jsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { Check, FileText } from "lucide-react"; +import StatusPill from "../components/StatusPill"; +import { SimpleSelect } from "../components/ui/select"; +import { FormRow, Metric, PageActions, WindowBody, WindowTabs, WizardSteps } from "../components/WindowUI"; + +const steps = ["来源导入", "能力提取", "效果评估", "发布员工"]; +const employeeTabs = ["概览", "能力与授权", "评估报告", "版本历史", "调用记录"]; + +export function DistillStartWindow() { + const [step, setStep] = useState(0); + const [notice, setNotice] = useState("当前为页面演示,数据不会提交到后台"); + const [name, setName] = useState("销售冠军 Aileen"); + + const pages = [ + , + , + , + , + ]; + + return
{pages[step]}{step > 0 ? : null}{step < 3 ? : }
; +} + +function SourceStep() { + return
{[["champion-scripts.docx", "38 条话术"]].map(([name, meta]) =>
{name}

{meta}

)}
; +} + + +function ExtractStep() { + return

正在提取能力

任务可在后台继续运行

72%
{[["数据清洗", "1,286 / 1,286", true], ["场景聚类", "18 个场景", true], ["策略提取", "13 / 18", false], ["边界归纳", "等待", false], ["反例生成", "等待", false]].map(([name, value, done]) =>
{done ? : }{name}{value}
)}

已识别能力

{["高意向识别", "异议拆解", "温和推进", "优惠边界", "长期跟进"].map((item, index) => {item})}
; +} + +function EvaluateStep() { + return
92
综合评分 / 100
{[["高意向询价", "建议合理", true], ["超边界优惠", "已拒绝越权", true], ["售后退款混合场景", "回答包含不确定信息", false]].map(([name, result, passed]) =>
{passed ? "✓" : "!"} {name}{result}
)}
; +} + +function PublishStep({ name, setName }) { + return
setName(e.target.value)} />

授权能力

{["客户意图识别", "报价策略生成", "售后安抚助手"].map((item, index) => )}

授权知识

{["产品价格表", "报价与折扣边界", "售后退款政策"].map((item) => )}
; +} + +export function EmployeeDetailWindow() { + const [active, setActive] = useState("概览"); + const [enabled, setEnabled] = useState(true); + return

销售冠军 Aileen

v3.2

企微私域成交 · 更新于 2026-06-18

{active === "概览" ? : }
; +} + +function EmployeeOverview() { + return

员工画像

高意向逼单 / 异议拆解 / 温和推进

适用:已有明确需求、处于报价或决策阶段的私域客户
禁止:承诺未授权折扣、绕过人工确认直接发送敏感信息

绑定能力:客户意图识别、报价策略生成
绑定知识:产品价格表、报价边界、高意向案例
; +} + +function EmployeeTab({ active }) { + const rows = { "能力与授权": ["客户意图识别 · 已授权", "报价策略生成 · 已授权", "售后安抚助手 · 未授权", "产品价格表 · 已绑定"], "评估报告": ["综合评分 96 / 100", "场景命中 97%", "拒答准确 94%", "一致性 93%"], "版本历史": ["v3.2 · 优化温和推进策略 · 当前", "v3.1 · 新增长期跟进场景", "v3.0 · 重新蒸馏 1,286 条会话"], "调用记录": ["12:26 · 高意向询价 · 建议已采纳", "11:48 · 优惠边界 · 等待人工确认", "10:13 · 售后混合场景 · 已回退基础提示词"] }[active] || []; + return
{rows.map((row) =>
{row}
)}
; +} diff --git a/src/windows/EngineLogsWindow.jsx b/src/windows/EngineLogsWindow.jsx new file mode 100644 index 0000000..4532e8c --- /dev/null +++ b/src/windows/EngineLogsWindow.jsx @@ -0,0 +1,50 @@ +import { useMemo, useState } from "react"; +import { Copy, Download, Pause, Play, Search, Trash2, X } from "lucide-react"; +import { SimpleSelect } from "../components/ui/select"; +import { logs } from "../data/mockData"; + +const modules = ["agent", "vision", "capture", "retriever", "model", "policy", "sender"]; +const enrichedLogs = [...logs, ...logs, ...logs].map((item, index) => ({ + ...item, + id: index + 1, + time: `12:${String(30 + Math.floor(index / 12)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`, + module: modules[index % modules.length], + requestId: index % 4 === 0 ? `req_${91 + index}fa` : "—", +})); + +export default function EngineLogsWindow() { + const [level, setLevel] = useState("全部级别"); + const [module, setModule] = useState("全部模块"); + const [query, setQuery] = useState(""); + const [paused, setPaused] = useState(false); + const [visible, setVisible] = useState(enrichedLogs); + const [selected, setSelected] = useState(null); + + const filtered = useMemo(() => visible.filter((item) => { + const matchLevel = level === "全部级别" || item.level === level; + const matchModule = module === "全部模块" || item.module === module; + const text = `${item.message} ${item.requestId}`.toLowerCase(); + return matchLevel && matchModule && text.includes(query.toLowerCase()); + }), [level, module, query, visible]); + + return ( +
+
+
+
+
+
setQuery(e.target.value)} placeholder="搜索消息或 requestId" />
+
+
+ +
+
+
+ {filtered.length ? filtered.map((item) => ) :
当前筛选下暂无日志
} +
+
已显示 {filtered.length} / {visible.length} 条 · 缓存上限 10,000{paused ? "已暂停接收 · 新日志 3 条" : "最后更新 12:30:15"}
+
+ {selected ? : null} +
+ ); +} diff --git a/src/windows/EngineWorkbench.jsx b/src/windows/EngineWorkbench.jsx deleted file mode 100644 index 227e292..0000000 --- a/src/windows/EngineWorkbench.jsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { Bot, SendHorizontal, UserRound } from "lucide-react"; - -const MONITOR_ENDPOINT = "http://127.0.0.1:8765"; - -function messageLabel(role) { - if (role === "me") return "我"; - if (role === "user") return "客户"; - return "未知"; -} - -export default function EngineWorkbench() { - const [monitorEvent, setMonitorEvent] = useState(null); - const [streamUrl, setStreamUrl] = useState(`${MONITOR_ENDPOINT}/frame.jpg?t=${Date.now()}`); - const [streamFps, setStreamFps] = useState(0); - const [streamError, setStreamError] = useState(""); - const [annotation, setAnnotation] = useState(null); - const [frameMeta, setFrameMeta] = useState(null); - const [videoBox, setVideoBox] = useState({ width: 0, height: 0 }); - const [question, setQuestion] = useState(""); - const [qaMessages, setQaMessages] = useState([]); - const sequenceRef = useRef({ sequence: null, time: 0 }); - const streamRefreshedRef = useRef(false); - - useEffect(() => { - if (!window.__TAURI_INTERNALS__) return; - invoke("start_vision_stream").catch((error) => { - setStreamError(`预览服务启动失败:${String(error)}`); - }); - invoke("load_regions") - .then((saved) => setAnnotation(saved || null)) - .catch(() => setAnnotation(null)); - }, []); - - useEffect(() => { - const updateBox = () => { - const element = document.getElementById("wechat-preview-frame"); - if (!element) return; - const rect = element.getBoundingClientRect(); - setVideoBox({ width: rect.width, height: rect.height }); - }; - updateBox(); - window.addEventListener("resize", updateBox); - return () => window.removeEventListener("resize", updateBox); - }, []); - - useEffect(() => { - let cancelled = false; - - async function pollMonitor() { - try { - const response = await fetch(`${MONITOR_ENDPOINT}/latest.json?t=${Date.now()}`, { cache: "no-store" }); - const data = await response.json(); - if (!cancelled) { - if (data.source !== "go-window-capture") { - setMonitorEvent(null); - setStreamFps(0); - setStreamError(`预览服务来源异常:${data.source || "unknown"}`); - return; - } - if (data.event?.type === "source_waiting") { - setMonitorEvent(null); - setStreamFps(0); - setStreamError(data.event.message || "等待标注来源窗口出现..."); - setFrameMeta(data.frame || null); - return; - } - setStreamError(""); - setFrameMeta(data.frame || null); - const now = performance.now(); - const previous = sequenceRef.current; - if (typeof data.sequence === "number" && typeof previous.sequence === "number" && previous.time) { - const elapsedSeconds = Math.max((now - previous.time) / 1000, 0.001); - setStreamFps(Math.max(0, (data.sequence - previous.sequence) / elapsedSeconds)); - } - sequenceRef.current = { sequence: data.sequence, time: now }; - setMonitorEvent(data.event?.type === "preview_frame" ? data.event : null); - if (typeof data.sequence === "number" && data.sequence > 0) { - streamRefreshedRef.current = true; - setStreamUrl(`${MONITOR_ENDPOINT}/frame.jpg?seq=${data.sequence}&t=${Date.now()}`); - } - } - } catch { - if (!cancelled) { - setMonitorEvent(null); - setStreamError("等待预览服务启动..."); - } - } - } - - pollMonitor(); - const timer = window.setInterval(pollMonitor, 500); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, []); - - const latestLlm = monitorEvent?.latest_llm || monitorEvent?.llm_chat_reading || null; - const visibleMessages = useMemo(() => latestLlm?.visible_messages || [], [latestLlm]); - const latestUserMessage = latestLlm?.latest_user_message || "等待识别当前聊天记录"; - const replyText = latestLlm?.reply_text || "暂无建议回复"; - const overlay = useMemo(() => { - const frameWidth = frameMeta?.width || annotation?.screenshotWidth; - const frameHeight = frameMeta?.height || annotation?.screenshotHeight; - if (!frameWidth || !frameHeight || !videoBox.width || !videoBox.height) return null; - const scale = Math.min(videoBox.width / frameWidth, videoBox.height / frameHeight); - const width = frameWidth * scale; - const height = frameHeight * scale; - return { - left: (videoBox.width - width) / 2, - top: (videoBox.height - height) / 2, - scale, - frameWidth, - frameHeight, - }; - }, [annotation, frameMeta, videoBox]); - - function regionStyle(region) { - if (!overlay || !region.bbox_image) return null; - const sourceWidth = annotation?.screenshotWidth || overlay.frameWidth; - const sourceHeight = annotation?.screenshotHeight || overlay.frameHeight; - const [ix1, iy1, ix2, iy2] = region.bbox_image; - const x1 = (ix1 * overlay.frameWidth) / sourceWidth; - const y1 = (iy1 * overlay.frameHeight) / sourceHeight; - const x2 = (ix2 * overlay.frameWidth) / sourceWidth; - const y2 = (iy2 * overlay.frameHeight) / sourceHeight; - return { - left: overlay.left + x1 * overlay.scale, - top: overlay.top + y1 * overlay.scale, - width: Math.max(1, (x2 - x1) * overlay.scale), - height: Math.max(1, (y2 - y1) * overlay.scale), - }; - } - - function submitQuestion(event) { - event.preventDefault(); - const trimmed = question.trim(); - if (!trimmed) return; - - const contextLines = visibleMessages.map((message) => `${messageLabel(message.role)}:${message.content}`).join("\n"); - const answer = contextLines - ? `基于当前已识别聊天记录:\n${contextLines}\n\n建议回复:${replyText}` - : "当前还没有读取到聊天记录。请先确认监控服务已启动,并等待检测到新消息后再询问。"; - - setQaMessages((current) => [ - ...current, - { role: "user", content: trimmed }, - { role: "assistant", content: answer }, - ]); - setQuestion(""); - } - - return ( -
-
-
-
-
-

微信窗口实时画面

-

- {monitorEvent?.target_label ? `预览来源:${monitorEvent.target_label}` : `来自本地监控流:${MONITOR_ENDPOINT}/stream.mjpg`} -

-
-
- {streamFps ? `${streamFps.toFixed(1)} FPS` : "FPS --"} - {monitorEvent ? "在线" : "等待监控服务"} -
-
-
- 微信窗口实时画面 { - window.setTimeout(() => { - setStreamUrl(`${MONITOR_ENDPOINT}/frame.jpg?t=${Date.now()}`); - }, 1000); - }} - /> -
- {streamFps ? `${streamFps.toFixed(1)} FPS` : "等待帧率"} -
- {annotation?.regions?.map((region) => { - const style = regionStyle(region); - if (!style) return null; - return ( -
-
- {region.type || region.name} -
-
- ); - })} - {streamError ? ( -
- {streamError} -
- ) : null} -
-
-
- - -
- ); -} diff --git a/src/windows/KnowledgeWindows.jsx b/src/windows/KnowledgeWindows.jsx new file mode 100644 index 0000000..1ba7a3b --- /dev/null +++ b/src/windows/KnowledgeWindows.jsx @@ -0,0 +1,418 @@ +import { useEffect, useRef, useState } from "react"; +import { AlertTriangle, FileText, Image, MoreHorizontal, Upload, Video, X } from "lucide-react"; +import StatusPill from "../components/StatusPill"; +import { FormRow, InfoRow, Metric, PageActions, WindowBody, WindowTabs, WizardSteps } from "../components/WindowUI"; +import { extractDocumentText } from "../utils/documentText"; +import { closeWindow, openWindow } from "../utils/navigation"; + +const steps = ["基本信息", "来源导入", "知识库预览"]; +const fullText = `第一章 退款申请范围\n\n退款申请需在订单完成后 7 日内提交。已使用的数字商品不支持无理由退款。涉及异常订单时,应先核对订单状态、支付记录与商品交付状态。\n\n第二章 异常订单处理\n\n客户情绪激动时先确认问题,再说明退款边界。不得承诺未授权补偿;需要升级处理时,应生成清晰的问题摘要并转交售后负责人。`; +const initialFiles = [ + { name: "refund-policy.pdf", size: "2.4 MB", status: "已转换为 TXT", kind: "text", text: fullText }, + { name: "service-example.docx", size: "860 KB", status: "已转换为 TXT", kind: "text", text: fullText }, + { name: "售后退款流程图.svg", size: "2.2 KB", status: "可预览", kind: "image", previewUrl: "/media/refund-process.svg", description: "售后退款四步流程图,展示提交申请、核验订单、审核处理和退款完成的关键节点。" }, + { name: "退款处理演示.mp4", size: "96 KB", status: "可预览", kind: "video", previewUrl: "/media/refund-guide.mp4", description: "退款处理演示视频,用于说明售后流程卡片和各阶段状态变化。" }, +]; + +const KNOWLEDGE_VERSION_CHANNEL = "knowledge-version-updates"; + +function knowledgeVersionKey(knowledgeId) { + return `knowledge-version:${knowledgeId}`; +} + +function incrementMinorVersion(version) { + const match = /^v(\d+)\.(\d+)$/.exec(version); + if (!match) return "v1.1"; + return `v${match[1]}.${Number(match[2]) + 1}`; +} + +function getPreviewKind(fileName) { + const extension = fileName.split(".").pop()?.toLowerCase(); + if (["png", "jpg", "jpeg", "gif", "webp"].includes(extension)) return "image"; + if (["mp4", "webm", "mov"].includes(extension)) return "video"; + return "text"; +} + + +export function KnowledgeCreateWindow({ mode = "create", knowledgeId = "kb-refund", currentVersion = "v1.7" }) { + const [step, setStep] = useState(0); + const [title, setTitle] = useState("售后退款政策"); + const [tags, setTags] = useState(["私域成交", "售后"]); + const [tagInput, setTagInput] = useState(""); + const [files, setFiles] = useState(initialFiles); + const [activeFileName, setActiveFileName] = useState(""); + const [descriptionDraft, setDescriptionDraft] = useState(""); + const [notice, setNotice] = useState(mode === "update" ? `当前版本 ${currentVersion},保存后将生成新版本` : "内容尚未写入数据库"); + const inputRef = useRef(null); + const activeFile = files.find((file) => file.name === activeFileName) || null; + + async function addFiles(event) { + const sourceFiles = Array.from(event.target.files || []); + const selected = await Promise.all(sourceFiles.map(async (file) => { + const kind = getPreviewKind(file.name); + let text = ""; + let conversionError = ""; + if (kind === "text") { + try { + text = await extractDocumentText(file); + } catch (error) { + conversionError = error instanceof Error ? error.message : String(error); + } + } + return { + name: file.name, + size: `${Math.max(1, Math.round(file.size / 1024))} KB`, + status: kind === "text" ? (conversionError ? "转换失败" : "已转换为 TXT") : "可预览", + kind, + text, + error: conversionError, + previewUrl: kind === "text" ? "" : URL.createObjectURL(file), + }; + })); + setFiles((current) => [...current, ...selected]); + const failedCount = selected.filter((file) => file.error).length; + setNotice(failedCount ? `${failedCount} 个文档转换失败,请检查文件格式` : `已加入 ${selected.length} 个文件`); + event.target.value = ""; + } + + function removeFile(file) { + if (file.previewUrl) URL.revokeObjectURL(file.previewUrl); + setFiles((current) => current.filter((item) => item.name !== file.name)); + if (activeFileName === file.name) setActiveFileName(""); + } + + function addTag() { + const nextTag = tagInput.trim(); + if (!nextTag) return; + if (!tags.includes(nextTag)) setTags((current) => [...current, nextTag]); + setTagInput(""); + } + + function openFilePreview(file) { + setActiveFileName(file.name); + setDescriptionDraft(file.description || ""); + } + + function closeFilePreview() { + setActiveFileName(""); + setDescriptionDraft(""); + } + + function saveDescription() { + if (!activeFile || !["image", "video"].includes(activeFile.kind)) return; + setFiles((current) => current.map((file) => ( + file.name === activeFile.name ? { ...file, description: descriptionDraft.trim() } : file + ))); + setNotice(`已保存 ${activeFile.name} 的内容描述`); + } + + function next() { + if (step === 0 && !title.trim()) { + setNotice("请先填写知识标题"); + return; + } + if (step === 1 && files.length === 0) { + setNotice("请至少选择一个来源文件"); + return; + } + setStep((current) => Math.min(2, current + 1)); + setNotice("草稿已保留在当前窗口"); + } + + function saveKnowledge() { + if (mode !== "update") { + setNotice("发布完成(页面演示)"); + setTimeout(closeWindow, 500); + return; + } + + const nextVersion = incrementMinorVersion(currentVersion); + localStorage.setItem(knowledgeVersionKey(knowledgeId), nextVersion); + if ("BroadcastChannel" in window) { + const channel = new BroadcastChannel(KNOWLEDGE_VERSION_CHANNEL); + channel.postMessage({ knowledgeId, version: nextVersion }); + channel.close(); + } + setNotice(`更新已保存,新版本为 ${nextVersion}`); + setTimeout(closeWindow, 500); + } + + + function renderStep() { + if (step === 0) { + return ( +
+ setTitle(event.target.value)} /> + +
+ {tags.map((tag) => )} + setTagInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + addTag(); + } + }} + /> +
+
+