Implement desktop workflows and native annotation overlay

This commit is contained in:
大森 2026-07-22 08:10:25 +08:00
parent 53d48c450a
commit 83feece021
57 changed files with 14678 additions and 872 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@ node_modules/
dist/ dist/
.vite/ .vite/
.local-tools/
src-tauri/target/ src-tauri/target/
src-tauri/gen/ src-tauri/gen/

416
RPA.md Normal file
View File

@ -0,0 +1,416 @@
# Desktop MarkCtrl+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<OverlayFrame>('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<OverlayFrame>('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`

View File

@ -104,7 +104,6 @@ func runChatSync() error {
return err return err
} }
logInfo(fmt.Sprintf("聊天记录同步完成messages=%d path=%s", len(archive.Messages), archivePath)) logInfo(fmt.Sprintf("聊天记录同步完成messages=%d path=%s", len(archive.Messages), archivePath))
return nil return nil
} }
@ -132,7 +131,7 @@ func scrollChatToTop(config Config, summaries []RegionSummary, screenshotPath st
return captureWindow(window, screenshotPath) 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"] chat, ok := regionByType(summaries)["chat_content"]
if !ok { if !ok {
return fmt.Errorf("chat_content region not found") return fmt.Errorf("chat_content region not found")

604
demos/node-sphere-lab.html Normal file
View File

@ -0,0 +1,604 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Node Sphere Lab · 节点球实验室</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
color-scheme: dark;
--bg: #070b14;
--panel: rgba(13, 20, 35, .82);
--line: rgba(148, 163, 184, .16);
--muted: #8190a8;
--text: #edf4ff;
--idle: #738197;
--running: #39e6a5;
--error: #ff5f74;
--accent: #7c6cff;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: #dce4ee; font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
button { font: inherit; }
.browser-shell { width: min(1320px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); }
.app-grid { display: grid; grid-template-columns: 272px minmax(0, 1fr); }
.glass { background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(20px); }
.canvas-wrap { min-height: 0; background: linear-gradient(135deg, rgba(14,22,40,.78), rgba(6,10,19,.94)); }
#scene { display: block; width: 100%; height: 100%; cursor: crosshair; }
.mode-btn, .action-btn, .status-btn { transition: .2s ease; }
.mode-btn:hover, .action-btn:hover, .status-btn:hover { transform: translateY(-1px); }
.mode-btn.active { color: #fff; border-color: rgba(124,108,255,.52); background: linear-gradient(120deg, rgba(124,108,255,.24), rgba(65,215,255,.08)); box-shadow: inset 3px 0 0 #8879ff; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 10px currentColor; }
.metric { border-top: 1px solid var(--line); }
.node-controls-disabled { opacity: .28; pointer-events: none; filter: saturate(.35); }
.grain { background-image: radial-gradient(rgba(255,255,255,.1) .55px, transparent .55px); background-size: 6px 6px; opacity: .09; }
.tooltip { pointer-events: none; transform: translate(-50%, calc(-100% - 14px)); }
@media (max-width: 860px) {
body { overflow: auto; }
.browser-shell { width: 100%; height: 100vh; border-radius: 0 !important; }
.app-grid { grid-template-columns: 1fr; }
aside { display: none !important; }
}
</style>
</head>
<body class="grid min-h-screen place-items-center p-4 text-slate-900">
<main class="browser-shell flex flex-col overflow-hidden rounded-[22px] border border-slate-300 bg-white shadow-2xl">
<div class="shrink-0 border-b border-slate-300 bg-slate-100">
<div class="flex h-9 items-end gap-1 px-3 pt-2">
<div class="flex h-7 w-56 items-center gap-2 rounded-t-xl bg-white px-3 text-xs font-medium text-slate-700 shadow-sm">
<span class="grid h-4 w-4 place-items-center rounded bg-violet-600 text-white"><i data-lucide="orbit" class="h-3 w-3"></i></span>
Node Sphere Lab
</div>
<button class="grid h-7 w-8 place-items-center rounded-t-lg text-slate-500 hover:bg-slate-200" aria-label="新建标签"><i data-lucide="plus" class="h-4 w-4"></i></button>
</div>
<div class="flex h-11 items-center gap-2 px-3">
<button class="text-slate-400" aria-label="后退"><i data-lucide="arrow-left" class="h-4 w-4"></i></button>
<button class="text-slate-400" aria-label="前进"><i data-lucide="arrow-right" class="h-4 w-4"></i></button>
<button class="text-slate-500" aria-label="刷新"><i data-lucide="rotate-cw" class="h-4 w-4"></i></button>
<div class="flex flex-1 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs text-slate-500 shadow-inner">
<i data-lucide="lock-keyhole" class="h-3.5 w-3.5"></i>
localhost/demos/node-sphere-lab.html
</div>
<i data-lucide="more-vertical" class="h-4 w-4 text-slate-500"></i>
</div>
</div>
<div class="app-grid min-h-0 flex-1 overflow-hidden bg-[var(--bg)] text-[var(--text)]">
<aside class="glass z-10 flex min-h-0 flex-col border-y-0 border-l-0 p-4">
<div class="mb-5 flex items-center gap-3 px-1">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-violet-500/15 text-violet-300 ring-1 ring-violet-400/25">
<i data-lucide="atom" class="h-5 w-5"></i>
</div>
<div>
<h1 class="m-0 text-sm font-semibold tracking-wide">节点球实验室</h1>
<p class="m-0 mt-1 text-[11px] text-[var(--muted)]">NODE SPHERE LAB / 05</p>
</div>
</div>
<p class="mb-2 px-1 text-[10px] font-semibold tracking-[.2em] text-[var(--muted)]">视觉方案</p>
<nav class="grid gap-1.5" aria-label="节点球方案">
<button class="mode-btn active flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="orbit">
<i data-lucide="orbit" class="h-4 w-4 text-violet-300"></i><span><b class="block font-medium">轨道星环</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">多层椭圆轨道</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="core">
<i data-lucide="radar" class="h-4 w-4 text-cyan-300"></i><span><b class="block font-medium">量子核心</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">脉冲能量核心</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="mesh">
<i data-lucide="share-2" class="h-4 w-4 text-emerald-300"></i><span><b class="block font-medium">星图矩阵</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">动态拓扑连线</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="helix">
<i data-lucide="dna" class="h-4 w-4 text-pink-300"></i><span><b class="block font-medium">双螺旋</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">纵深粒子流</small></span>
</button>
<button class="mode-btn flex items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left text-sm text-slate-300" data-mode="scatter">
<i data-lucide="circle-dashed" class="h-4 w-4 text-sky-300"></i><span><b class="block font-medium">粒子轨道球</b><small class="mt-0.5 block text-[10px] text-[var(--muted)]">无节点散射球体</small></span>
</button>
</nav>
<div class="mt-auto rounded-2xl border border-white/10 bg-white/[.035] p-3.5 transition" data-node-controls>
<div class="mb-3 flex items-center justify-between">
<span class="text-xs text-[var(--muted)]">节点数量</span>
<strong id="nodeCountSide" class="font-mono text-xl font-medium">12</strong>
</div>
<div class="grid grid-cols-2 gap-2">
<button id="removeNode" class="action-btn flex h-9 items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/5 text-xs text-slate-300 hover:bg-white/10"><i data-lucide="minus" class="h-3.5 w-3.5"></i>减少</button>
<button id="addNode" class="action-btn flex h-9 items-center justify-center gap-1.5 rounded-lg bg-violet-500 text-xs font-medium text-white hover:bg-violet-400"><i data-lucide="plus" class="h-3.5 w-3.5"></i>增加</button>
</div>
</div>
</aside>
<section class="relative flex min-h-0 min-w-0 flex-col overflow-hidden">
<div class="grain pointer-events-none absolute inset-0 z-0"></div>
<header class="glass z-10 flex h-[74px] shrink-0 items-center justify-between border-x-0 border-t-0 px-5">
<div>
<div class="flex items-center gap-2">
<h2 id="modeTitle" class="m-0 text-lg font-semibold">轨道星环</h2>
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-medium text-emerald-300">LIVE</span>
</div>
<p id="modeDesc" class="m-0 mt-1 text-xs text-[var(--muted)]">节点围绕核心多轨道运行,粒子沿轨迹持续旋转</p>
</div>
<div class="flex items-center gap-2">
<button id="shuffleStatus" data-node-controls class="action-btn flex h-9 items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-slate-300 transition hover:bg-white/10"><i data-lucide="shuffle" class="h-3.5 w-3.5"></i>随机状态</button>
<button id="toggleMotion" class="action-btn grid h-9 w-9 place-items-center rounded-lg border border-white/10 bg-white/5 text-slate-300 hover:bg-white/10" aria-label="暂停动画"><i data-lucide="pause" class="h-4 w-4"></i></button>
</div>
</header>
<div class="canvas-wrap relative min-h-0 flex-1 overflow-hidden">
<canvas id="scene" aria-label="动态节点球画布"></canvas>
<div class="pointer-events-none absolute left-5 top-5 flex gap-2">
<span class="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 font-mono text-[10px] text-slate-400 backdrop-blur"><span class="mr-1.5 text-emerald-300"></span>FPS <b id="fps" class="text-slate-200">60</b></span>
<span class="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 font-mono text-[10px] text-slate-400 backdrop-blur">PARTICLES <b id="particleCount" class="text-slate-200">96</b></span>
</div>
<div id="tooltip" class="tooltip absolute z-20 hidden min-w-28 rounded-xl border border-white/10 bg-slate-950/90 px-3 py-2 text-center shadow-xl backdrop-blur">
<strong id="tooltipName" class="block text-xs font-medium">NODE-01</strong>
<span id="tooltipStatus" class="mt-1 block text-[10px] text-emerald-300">运行中</span>
</div>
<div data-node-controls class="absolute bottom-5 left-1/2 flex -translate-x-1/2 gap-2 rounded-2xl border border-white/10 bg-slate-950/70 p-1.5 shadow-xl backdrop-blur transition">
<button class="status-btn flex items-center gap-2 rounded-xl px-3 py-2 text-[11px] text-slate-300 hover:bg-white/5" data-set-status="idle"><span class="status-dot text-[var(--idle)]" style="background:currentColor"></span>全部未启动</button>
<button class="status-btn flex items-center gap-2 rounded-xl px-3 py-2 text-[11px] text-slate-300 hover:bg-white/5" data-set-status="running"><span class="status-dot text-[var(--running)]" style="background:currentColor"></span>全部运行</button>
<button class="status-btn flex items-center gap-2 rounded-xl px-3 py-2 text-[11px] text-slate-300 hover:bg-white/5" data-set-status="error"><span class="status-dot text-[var(--error)]" style="background:currentColor"></span>模拟异常</button>
</div>
</div>
<footer data-node-controls class="glass z-10 grid h-[76px] shrink-0 grid-cols-4 border-x-0 border-b-0 transition">
<div class="flex items-center gap-3 px-5">
<i data-lucide="boxes" class="h-4 w-4 text-violet-300"></i><div><small class="block text-[10px] text-[var(--muted)]">总节点</small><b id="totalMetric" class="font-mono text-sm">12</b></div>
</div>
<div class="metric flex items-center gap-3 border-l border-t-0 px-5">
<span class="status-dot text-[var(--running)]" style="background:currentColor"></span><div><small class="block text-[10px] text-[var(--muted)]">运行中</small><b id="runningMetric" class="font-mono text-sm">8</b></div>
</div>
<div class="metric flex items-center gap-3 border-l border-t-0 px-5">
<span class="status-dot text-[var(--idle)]" style="background:currentColor"></span><div><small class="block text-[10px] text-[var(--muted)]">未启动</small><b id="idleMetric" class="font-mono text-sm">2</b></div>
</div>
<div class="metric flex items-center gap-3 border-l border-t-0 px-5">
<span class="status-dot text-[var(--error)]" style="background:currentColor"></span><div><small class="block text-[10px] text-[var(--muted)]">异常错误</small><b id="errorMetric" class="font-mono text-sm">2</b></div>
</div>
</footer>
</section>
</div>
</main>
<script>
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const stateColors = { idle: '#738197', running: '#39e6a5', error: '#ff5f74' };
const stateLabels = { idle: '未启动', running: '运行中', error: '异常错误' };
const modes = {
orbit: { title: '轨道星环', desc: '节点围绕核心多轨道运行,粒子沿轨迹持续旋转' },
core: { title: '量子核心', desc: '节点形成能量外壳,核心脉冲驱动环形粒子浪涌' },
mesh: { title: '星图矩阵', desc: '节点构成动态拓扑,相邻服务之间建立实时连接' },
helix: { title: '双螺旋', desc: '节点沿双螺旋排列,粒子在纵深通道中往复流动' },
scatter: { title: '粒子轨道球', desc: '无节点球体由旋转粒子构成,并持续向外释放散射能量' }
};
let nodes = [];
let mode = 'orbit';
let running = true;
let time = 0;
let lastFrame = performance.now();
let frameSamples = [];
let hovered = -1;
let escapeParticles = [];
let sphereEscapes = [];
let dpr = Math.min(window.devicePixelRatio || 1, 2);
function seededStatus(i) {
return i % 7 === 5 ? 'error' : i % 5 === 3 ? 'idle' : 'running';
}
function addNode() {
if (nodes.length >= 24) return;
const i = nodes.length;
nodes.push({ id: i + 1, status: seededStatus(i), x: 0, y: 0, r: 7 });
updateMetrics();
}
function removeNode() {
if (nodes.length <= 3) return;
nodes.pop();
hovered = -1;
updateMetrics();
}
for (let i = 0; i < 12; i++) addNode();
function resize() {
const rect = canvas.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
new ResizeObserver(resize).observe(canvas);
function updateMetrics() {
const counts = { idle: 0, running: 0, error: 0 };
nodes.forEach(n => counts[n.status]++);
document.getElementById('nodeCountSide').textContent = nodes.length;
document.getElementById('totalMetric').textContent = nodes.length;
document.getElementById('runningMetric').textContent = counts.running;
document.getElementById('idleMetric').textContent = counts.idle;
document.getElementById('errorMetric').textContent = counts.error;
document.getElementById('particleCount').textContent = nodes.length * 8;
}
function glowCircle(x, y, radius, color, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
const g = ctx.createRadialGradient(x - radius * .28, y - radius * .32, 0, x, y, radius * 1.7);
g.addColorStop(0, '#ffffff');
g.addColorStop(.12, color);
g.addColorStop(.55, color + '88');
g.addColorStop(1, color + '00');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, radius * 1.7, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawNode(node, x, y, scale = 1, label = true) {
const color = stateColors[node.status];
const pulse = node.status === 'error' ? 1 + Math.sin(time * 5 + node.id) * .14 : 1;
const r = (node.status === 'idle' ? 5.5 : 7) * scale * pulse;
node.x = x; node.y = y; node.r = Math.max(9, r + 4);
if (node.status !== 'idle') glowCircle(x, y, r * 2.2, color, node.status === 'error' ? .72 : .52);
ctx.save();
ctx.strokeStyle = color + (node.status === 'idle' ? '66' : 'cc');
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(x, y, r + 4, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.78)';
ctx.beginPath(); ctx.arc(x - r * .25, y - r * .3, Math.max(1.2, r * .2), 0, Math.PI * 2); ctx.fill();
if (label && scale > .72) {
ctx.fillStyle = hovered === node.id - 1 ? '#ffffff' : '#7f8ca2';
ctx.font = '9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(String(node.id).padStart(2, '0'), x, y + r + 15);
}
ctx.restore();
}
function drawParticle(x, y, color, size = 1.4, alpha = .7) {
ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill(); ctx.restore();
}
function drawOrbit(w, h) {
const cx = w / 2, cy = h / 2, base = Math.min(w, h) * .31;
ctx.save(); ctx.translate(cx, cy); ctx.rotate(-.16);
for (let ring = 0; ring < 3; ring++) {
const rx = base * (.72 + ring * .32), ry = rx * (.36 + ring * .055);
ctx.strokeStyle = ring === 1 ? 'rgba(124,108,255,.26)' : 'rgba(94,161,255,.14)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(0, 0, rx, ry, ring * .56, 0, Math.PI * 2); ctx.stroke();
for (let p = 0; p < nodes.length * 3; p++) {
const a = time * (.24 + ring * .08) + p / (nodes.length * 3) * Math.PI * 2;
const cr = Math.cos(ring * .56), sr = Math.sin(ring * .56);
const ox = Math.cos(a) * rx, oy = Math.sin(a) * ry;
drawParticle(ox * cr - oy * sr, ox * sr + oy * cr, ring === 1 ? '#9d91ff' : '#55d5ff', 1.1, .18 + (p % 5) * .08);
}
}
ctx.restore();
glowCircle(cx, cy, 36 + Math.sin(time * 2) * 3, '#7c6cff', .55);
ctx.strokeStyle = 'rgba(151,136,255,.32)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, 43 + Math.sin(time * 1.7) * 2, 0, Math.PI * 2); ctx.stroke();
nodes.forEach((node, i) => {
const ring = i % 3, rx = base * (.72 + ring * .32), ry = rx * (.36 + ring * .055);
const a = i / nodes.length * Math.PI * 2 + time * (.12 + ring * .025);
const rot = -.16 + ring * .56, ox = Math.cos(a) * rx, oy = Math.sin(a) * ry;
const x = cx + ox * Math.cos(rot) - oy * Math.sin(rot);
const y = cy + ox * Math.sin(rot) + oy * Math.cos(rot);
drawNode(node, x, y, .82 + (Math.sin(a) + 1) * .13);
});
}
function drawCore(w, h) {
const cx = w / 2, cy = h / 2, radius = Math.min(w, h) * .29;
for (let ring = 1; ring <= 5; ring++) {
const r = radius * (ring / 5) + Math.sin(time * 2 - ring) * 3;
ctx.strokeStyle = `rgba(72,198,255,${.16 - ring * .018})`;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
}
for (let p = 0; p < nodes.length * 8; p++) {
const layer = 1 + p % 4, a = p * 2.399 + time * (.32 + layer * .03);
const r = radius * (.24 + layer * .18) + Math.sin(time * 2 + p) * 8;
drawParticle(cx + Math.cos(a) * r, cy + Math.sin(a) * r * .82, p % 7 ? '#55d5ff' : '#a797ff', 1 + p % 3 * .35, .3 + p % 5 * .08);
}
glowCircle(cx, cy, 48 + Math.sin(time * 2.5) * 5, '#25c9ff', .42);
ctx.fillStyle = 'rgba(230,250,255,.9)'; ctx.beginPath(); ctx.arc(cx, cy, 8, 0, Math.PI * 2); ctx.fill();
nodes.forEach((node, i) => {
const a = i / nodes.length * Math.PI * 2 - time * .08;
const wobble = 1 + Math.sin(time * 1.4 + i * .8) * .07;
drawNode(node, cx + Math.cos(a) * radius * wobble, cy + Math.sin(a) * radius * .82 * wobble, .95);
});
}
function drawMesh(w, h) {
const cx = w / 2, cy = h / 2, radius = Math.min(w, h) * .35;
nodes.forEach((n, i) => {
const a = i * 2.39996 + time * .035;
const r = radius * (.28 + .72 * Math.sqrt((i + 1) / nodes.length));
n.x = cx + Math.cos(a) * r * 1.42;
n.y = cy + Math.sin(a) * r * .83;
});
// A soft additive bloom under every topology link, then a crisp core line.
ctx.save();
ctx.globalCompositeOperation = 'lighter';
nodes.forEach((a, i) => nodes.slice(i + 1).forEach(b => {
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < radius * .66) {
const danger = a.status === 'error' || b.status === 'error';
const color = danger ? '#ff5f74' : '#56b1ff';
const strength = Math.max(0, 1 - d / (radius * .66));
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 13;
ctx.strokeStyle = danger ? `rgba(255,95,116,${.08 + strength * .1})` : `rgba(86,177,255,${.07 + strength * .1})`;
ctx.lineWidth = 4.5;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.restore();
ctx.strokeStyle = danger ? `rgba(255,120,139,${.18 + strength * .22})` : `rgba(112,203,255,${.18 + strength * .26})`;
ctx.lineWidth = .75;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
// Two light packets travel in opposite directions across each link.
const flow = (time * .28 + i * .113 + b.id * .037) % 1;
const reverse = 1 - ((time * .18 + i * .071 + b.id * .043) % 1);
[flow, reverse].forEach((progress, packetIndex) => {
const px = a.x + (b.x - a.x) * progress;
const py = a.y + (b.y - a.y) * progress;
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 10;
drawParticle(px, py, danger ? '#ff9cab' : '#b7e8ff', packetIndex ? 1 : 1.45, .55 + strength * .35);
ctx.restore();
});
}
}));
ctx.restore();
// Random particles escape from nodes and drift away from the graph center.
if (running && nodes.length && escapeParticles.length < 90 && Math.random() < .12) {
const source = nodes[Math.floor(Math.random() * nodes.length)];
const angle = Math.atan2(source.y - cy, source.x - cx) + (Math.random() - .5) * 1.2;
const speed = 18 + Math.random() * 42;
escapeParticles.push({
x: source.x, y: source.y,
px: source.x, py: source.y,
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
life: .8 + Math.random() * 1.5,
maxLife: 0,
color: stateColors[source.status],
size: .7 + Math.random() * 1.6
});
escapeParticles[escapeParticles.length - 1].maxLife = escapeParticles[escapeParticles.length - 1].life;
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
escapeParticles.forEach(p => {
if (running) {
p.px = p.x; p.py = p.y;
p.x += p.vx / 60; p.y += p.vy / 60;
p.vx *= .996; p.vy = p.vy * .996 - .008;
p.life -= 1 / 60;
}
const alpha = Math.max(0, p.life / p.maxLife);
ctx.strokeStyle = p.color + Math.round(alpha * 130).toString(16).padStart(2, '0');
ctx.lineWidth = Math.max(.35, p.size * alpha);
ctx.shadowColor = p.color; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(p.px, p.py); ctx.lineTo(p.x, p.y); ctx.stroke();
drawParticle(p.x, p.y, p.color, p.size * (.55 + alpha * .45), alpha);
});
ctx.restore();
escapeParticles = escapeParticles.filter(p => p.life > 0 && p.x > -30 && p.x < w + 30 && p.y > -30 && p.y < h + 30);
nodes.forEach((node, i) => drawNode(node, node.x + Math.sin(time + i) * 2, node.y + Math.cos(time * .8 + i) * 2, .88));
}
function drawHelix(w, h) {
const cx = w / 2, cy = h / 2, span = Math.min(w * .68, 620), amp = Math.min(h * .24, 130);
ctx.lineWidth = 1;
for (let side = 0; side < 2; side++) {
ctx.strokeStyle = side ? 'rgba(255,102,179,.22)' : 'rgba(86,205,255,.24)';
ctx.beginPath();
for (let s = 0; s <= 100; s++) {
const t = s / 100, x = cx - span / 2 + span * t;
const y = cy + Math.sin(t * Math.PI * 4 + time * .55 + side * Math.PI) * amp;
s ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.stroke();
}
for (let p = 0; p < nodes.length * 8; p++) {
const t = (p / (nodes.length * 8) + time * .035) % 1;
const side = p % 2, phase = t * Math.PI * 4 + time * .55 + side * Math.PI;
const depth = (Math.cos(phase) + 1) / 2;
drawParticle(cx - span / 2 + span * t, cy + Math.sin(phase) * amp, side ? '#ff7ebd' : '#65d9ff', .8 + depth * 1.5, .25 + depth * .6);
}
nodes.forEach((node, i) => {
const t = nodes.length === 1 ? .5 : i / (nodes.length - 1), side = i % 2;
const phase = t * Math.PI * 4 + time * .55 + side * Math.PI;
const depth = (Math.cos(phase) + 1) / 2;
drawNode(node, cx - span / 2 + span * t, cy + Math.sin(phase) * amp, .68 + depth * .48, depth > .28);
});
}
function drawScatterSphere(w, h) {
const cx = w / 2, cy = h / 2, radius = Math.min(w, h) * .285;
const tilt = -.24;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// Six luminous great-circle tracks at different inclinations.
for (let ring = 0; ring < 6; ring++) {
const rotation = ring * Math.PI / 6 + time * (ring % 2 ? -.055 : .045);
const squash = .2 + (ring % 3) * .12;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rotation + tilt);
ctx.shadowColor = ring % 2 ? '#7c6cff' : '#4edbff';
ctx.shadowBlur = 12;
ctx.strokeStyle = ring % 2 ? 'rgba(145,126,255,.19)' : 'rgba(78,219,255,.16)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.ellipse(0, 0, radius, radius * squash, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
for (let p = 0; p < 18; p++) {
const a = p / 18 * Math.PI * 2 + time * (.34 + ring * .025) * (ring % 2 ? -1 : 1);
const ox = Math.cos(a) * radius;
const oy = Math.sin(a) * radius * squash;
const cr = Math.cos(rotation + tilt), sr = Math.sin(rotation + tilt);
const depth = (Math.sin(a) + 1) / 2;
drawParticle(cx + ox * cr - oy * sr, cy + ox * sr + oy * cr,
ring % 2 ? '#a79aff' : '#79e7ff', .65 + depth * 1.2, .12 + depth * .52);
}
}
// Fibonacci sphere: evenly distributed particles rotate as one 3D shell.
const shellCount = 240;
for (let i = 0; i < shellCount; i++) {
const y0 = 1 - (i / (shellCount - 1)) * 2;
const ringRadius = Math.sqrt(Math.max(0, 1 - y0 * y0));
const theta = i * 2.399963 + time * .22;
let x0 = Math.cos(theta) * ringRadius;
let z0 = Math.sin(theta) * ringRadius;
const cosY = Math.cos(time * .12), sinY = Math.sin(time * .12);
const xr = x0 * cosY - z0 * sinY;
const zr = x0 * sinY + z0 * cosY;
const perspective = .74 + (zr + 1) * .17;
const px = cx + xr * radius * perspective;
const py = cy + y0 * radius * perspective;
const alpha = .12 + (zr + 1) * .28;
const color = i % 9 === 0 ? '#b49cff' : i % 5 === 0 ? '#ffffff' : '#55d8ff';
drawParticle(px, py, color, .55 + (zr + 1) * .48, alpha);
}
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * .9);
core.addColorStop(0, 'rgba(93,124,255,.16)');
core.addColorStop(.52, 'rgba(52,174,255,.045)');
core.addColorStop(1, 'rgba(42,151,255,0)');
ctx.fillStyle = core;
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill();
// Random energy escapes from arbitrary points on the visible sphere edge.
if (running && sphereEscapes.length < 120 && Math.random() < .2) {
const angle = Math.random() * Math.PI * 2;
const startRadius = radius * (.7 + Math.random() * .28);
const speed = 28 + Math.random() * 70;
const tangent = (Math.random() - .5) * .45;
const direction = angle + tangent;
const particle = {
x: cx + Math.cos(angle) * startRadius,
y: cy + Math.sin(angle) * startRadius * .84,
vx: Math.cos(direction) * speed,
vy: Math.sin(direction) * speed,
life: .7 + Math.random() * 1.55,
maxLife: 0,
color: Math.random() > .35 ? '#5de0ff' : '#9c89ff',
size: .8 + Math.random() * 1.9
};
particle.px = particle.x; particle.py = particle.y; particle.maxLife = particle.life;
sphereEscapes.push(particle);
}
sphereEscapes.forEach(p => {
if (running) {
p.px = p.x; p.py = p.y;
p.x += p.vx / 60; p.y += p.vy / 60;
p.vx *= .998; p.vy *= .998; p.life -= 1 / 60;
}
const alpha = Math.max(0, p.life / p.maxLife);
ctx.shadowColor = p.color; ctx.shadowBlur = 10;
ctx.strokeStyle = p.color + Math.round(alpha * 155).toString(16).padStart(2, '0');
ctx.lineWidth = Math.max(.4, p.size * alpha);
ctx.beginPath(); ctx.moveTo(p.px, p.py); ctx.lineTo(p.x, p.y); ctx.stroke();
drawParticle(p.x, p.y, p.color, p.size * (.5 + alpha * .5), alpha);
});
sphereEscapes = sphereEscapes.filter(p => p.life > 0 && p.x > -40 && p.x < w + 40 && p.y > -40 && p.y < h + 40);
ctx.restore();
}
function drawGrid(w, h) {
ctx.save(); ctx.strokeStyle = 'rgba(105,133,178,.055)'; ctx.lineWidth = 1;
const gap = 44;
for (let x = (w / 2) % gap; x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
for (let y = (h / 2) % gap; y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
ctx.restore();
}
function frame(now) {
const rect = canvas.getBoundingClientRect();
const dt = Math.min((now - lastFrame) / 1000, .05); lastFrame = now;
if (running) time += dt;
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
({ orbit: drawOrbit, core: drawCore, mesh: drawMesh, helix: drawHelix, scatter: drawScatterSphere })[mode](rect.width, rect.height);
frameSamples.push(1 / Math.max(dt, .001));
if (frameSamples.length > 30) frameSamples.shift();
if (Math.floor(now / 500) !== Math.floor((now - dt * 1000) / 500)) {
document.getElementById('fps').textContent = Math.round(frameSamples.reduce((a,b) => a+b, 0) / frameSamples.length);
if (mode === 'scatter') document.getElementById('particleCount').textContent = 348 + sphereEscapes.length;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
document.querySelectorAll('.mode-btn').forEach(btn => btn.addEventListener('click', () => {
mode = btn.dataset.mode;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b === btn));
document.getElementById('modeTitle').textContent = modes[mode].title;
document.getElementById('modeDesc').textContent = modes[mode].desc;
const nodeFree = mode === 'scatter';
document.querySelectorAll('[data-node-controls]').forEach(el => el.classList.toggle('node-controls-disabled', nodeFree));
document.getElementById('particleCount').textContent = nodeFree ? 348 + sphereEscapes.length : nodes.length * 8;
hovered = -1; hideTooltip();
}));
document.getElementById('addNode').addEventListener('click', addNode);
document.getElementById('removeNode').addEventListener('click', removeNode);
document.getElementById('shuffleStatus').addEventListener('click', () => {
const values = ['running', 'running', 'running', 'idle', 'error'];
nodes.forEach(n => n.status = values[Math.floor(Math.random() * values.length)]);
updateMetrics();
});
document.querySelectorAll('[data-set-status]').forEach(btn => btn.addEventListener('click', () => {
nodes.forEach(n => n.status = btn.dataset.setStatus); updateMetrics();
}));
document.getElementById('toggleMotion').addEventListener('click', event => {
running = !running;
const btn = event.currentTarget;
btn.innerHTML = `<i data-lucide="${running ? 'pause' : 'play'}" class="h-4 w-4"></i>`;
btn.setAttribute('aria-label', running ? '暂停动画' : '继续动画');
lucide.createIcons();
});
function nodeAt(clientX, clientY) {
if (mode === 'scatter') return -1;
const rect = canvas.getBoundingClientRect(), x = clientX - rect.left, y = clientY - rect.top;
for (let i = nodes.length - 1; i >= 0; i--) if (Math.hypot(x - nodes[i].x, y - nodes[i].y) <= nodes[i].r + 5) return i;
return -1;
}
function hideTooltip() { document.getElementById('tooltip').classList.add('hidden'); }
canvas.addEventListener('pointermove', event => {
hovered = nodeAt(event.clientX, event.clientY);
if (hovered < 0) { hideTooltip(); return; }
const node = nodes[hovered], tip = document.getElementById('tooltip');
document.getElementById('tooltipName').textContent = `NODE-${String(node.id).padStart(2, '0')}`;
const status = document.getElementById('tooltipStatus');
status.textContent = stateLabels[node.status]; status.style.color = stateColors[node.status];
tip.style.left = `${node.x}px`; tip.style.top = `${node.y}px`; tip.classList.remove('hidden');
});
canvas.addEventListener('pointerleave', () => { hovered = -1; hideTooltip(); });
canvas.addEventListener('click', event => {
const i = nodeAt(event.clientX, event.clientY); if (i < 0) return;
const cycle = ['idle', 'running', 'error'];
nodes[i].status = cycle[(cycle.indexOf(nodes[i].status) + 1) % cycle.length];
updateMetrics();
});
updateMetrics();
lucide.createIcons();
</script>
</body>
</html>

2074
docs/api-design.md Normal file

File diff suppressed because it is too large Load Diff

808
docs/base-ui.css Normal file
View File

@ -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;
}
}

214
docs/development.md Normal file
View File

@ -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`
- 当前工作站是 WindowsTauri 标注链路已有 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/avfoundationTauri 标注截图已补充 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`

File diff suppressed because it is too large Load Diff

View File

@ -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 达到生产 L32/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<major>.<minor>` 字符串 | `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: "<n> 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 TXTPDF、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、聊天蒸馏、评估发布和在线反馈训练。相关页面保留为开发预览或从生产构建隐藏不能连接模拟成功也不能成为 G0G3 的依赖。
## 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` 进入原生 overlayReact `/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 端到端记录证明。

File diff suppressed because it is too large Load Diff

View File

@ -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 去实现,我建议把这份文档当作 **视觉与结构说明书**,不要把它当作前端工程迁移文档。

12
overlay.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>微信 RPA 标注覆盖层</title>
</head>
<body>
<div id="overlay-root" aria-label="窗口标注覆盖层"></div>
<script type="module" src="/src/overlay.js"></script>
</body>
</html>

488
package-lock.json generated
View File

@ -12,7 +12,10 @@
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jszip": "^3.10.1",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"mammoth": "^1.12.0",
"pdfjs-dist": "^6.1.200",
"postcss": "^8.5.0", "postcss": "^8.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -797,6 +800,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "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" "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": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -2002,6 +2264,15 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT" "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": { "node_modules/aria-hidden": {
"version": "1.2.6", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@ -2050,6 +2321,26 @@
"postcss": "^8.1.0" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.35", "version": "2.10.35",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", "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" "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": { "node_modules/braces": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@ -2199,6 +2496,12 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2240,12 +2543,27 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0" "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": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.371", "version": "1.5.371",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
@ -2438,6 +2756,18 @@
"node": ">= 0.4" "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": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -2495,6 +2825,12 @@
"node": ">=0.12.0" "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": { "node_modules/jiti": {
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@ -2534,6 +2870,27 @@
"node": ">=6" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -2552,6 +2909,17 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "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" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -2663,12 +3055,45 @@
"node": ">= 6" "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": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2861,6 +3286,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -2989,6 +3420,21 @@
"pify": "^2.3.0" "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": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -3099,6 +3545,12 @@
"queue-microtask": "^1.2.2" "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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -3114,6 +3566,12 @@
"semver": "bin/semver.js" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3123,6 +3581,21 @@
"node": ">=0.10.0" "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": { "node_modules/sucrase": {
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -3284,6 +3757,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "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" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -14,7 +14,10 @@
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jszip": "^3.10.1",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"mammoth": "^1.12.0",
"pdfjs-dist": "^6.1.200",
"postcss": "^8.5.0", "postcss": "^8.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

Binary file not shown.

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
<title id="title">售后退款流程图</title>
<desc id="desc">展示退款申请、订单核验、审核处理和退款完成四个步骤。</desc>
<rect width="960" height="540" fill="#f4f7f5"/>
<rect x="56" y="48" width="848" height="444" rx="24" fill="#ffffff" stroke="#dde4df" stroke-width="2"/>
<circle cx="102" cy="96" r="18" fill="#07c160"/>
<path d="M94 96l6 6 11-13" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<text x="136" y="106" fill="#151716" font-family="Microsoft YaHei UI, sans-serif" font-size="28" font-weight="700">售后退款处理流程</text>
<text x="136" y="136" fill="#66716c" font-family="Microsoft YaHei UI, sans-serif" font-size="16">订单完成后 7 日内可提交退款申请</text>
<line x1="118" y1="270" x2="842" y2="270" stroke="#cfeada" stroke-width="8" stroke-linecap="round"/>
<g font-family="Microsoft YaHei UI, sans-serif" text-anchor="middle">
<g transform="translate(148 270)"><circle r="36" fill="#07c160"/><text y="8" fill="#fff" font-size="22" font-weight="700">1</text><text y="72" fill="#151716" font-size="18" font-weight="700">提交申请</text><text y="100" fill="#66716c" font-size="14">填写原因与凭证</text></g>
<g transform="translate(368 270)"><circle r="36" fill="#07c160"/><text y="8" fill="#fff" font-size="22" font-weight="700">2</text><text y="72" fill="#151716" font-size="18" font-weight="700">核验订单</text><text y="100" fill="#66716c" font-size="14">确认支付与交付状态</text></g>
<g transform="translate(588 270)"><circle r="36" fill="#07c160"/><text y="8" fill="#fff" font-size="22" font-weight="700">3</text><text y="72" fill="#151716" font-size="18" font-weight="700">审核处理</text><text y="100" fill="#66716c" font-size="14">判断退款范围</text></g>
<g transform="translate(808 270)"><circle r="36" fill="#151716"/><text y="8" fill="#fff" font-size="22" font-weight="700">4</text><text y="72" fill="#151716" font-size="18" font-weight="700">退款完成</text><text y="100" fill="#66716c" font-size="14">同步处理结果</text></g>
</g>
<rect x="92" y="430" width="776" height="38" rx="8" fill="#edf9f2"/>
<text x="480" y="455" text-anchor="middle" fill="#28794b" font-family="Microsoft YaHei UI, sans-serif" font-size="15">数字商品已使用时不支持无理由退款,异常订单需升级售后负责人</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
src-tauri/Cargo.lock generated
View File

@ -98,6 +98,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-shell", "tauri-plugin-shell",
"windows-sys 0.61.2",
"xcap", "xcap",
] ]

View File

@ -27,3 +27,4 @@ tauri = { version = "2.11.2", features = ["protocol-asset", "macos-private-api"]
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-shell = "2.3.5" tauri-plugin-shell = "2.3.5"
xcap = "0.6" 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"] }

View File

@ -1,3 +1,50 @@
fn main() { fn main() {
stop_stale_sidecar();
tauri_build::build() 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() {}

View File

@ -1,3 +1,5 @@
mod window_capture;
use screenshots::Screen; use screenshots::Screen;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@ -5,6 +7,7 @@ use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Instant; use std::time::Instant;
use tauri::webview::PageLoadEvent;
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_shell::{ use tauri_plugin_shell::{
process::{CommandChild, CommandEvent}, process::{CommandChild, CommandEvent},
@ -15,6 +18,26 @@ use xcap::{Monitor, Window};
struct AgentProcess(Mutex<Option<CommandChild>>); struct AgentProcess(Mutex<Option<CommandChild>>);
struct VisionStreamProcess(Mutex<Option<CommandChild>>); struct VisionStreamProcess(Mutex<Option<CommandChild>>);
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)] #[derive(Clone, Deserialize, Serialize)]
struct AgentLog { struct AgentLog {
level: String, level: String,
@ -24,63 +47,63 @@ struct AgentLog {
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CaptureResult { pub(crate) struct CaptureResult {
screenshot_path: String, pub(crate) screenshot_path: String,
screenshot_width: u32, pub(crate) screenshot_width: u32,
screenshot_height: u32, pub(crate) screenshot_height: u32,
scale_factor: f64, pub(crate) scale_factor: f64,
source: Option<CaptureSource>, pub(crate) source: Option<CaptureSource>,
screen_list_ms: u128, pub(crate) screen_list_ms: u128,
capture_ms: u128, pub(crate) capture_ms: u128,
save_ms: u128, pub(crate) save_ms: u128,
total_ms: u128, pub(crate) total_ms: u128,
} }
#[derive(Clone, Deserialize, Serialize)] #[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CaptureSource { pub(crate) struct CaptureSource {
id: String, pub(crate) id: String,
kind: String, pub(crate) kind: String,
label: String, pub(crate) label: String,
app_name: Option<String>, pub(crate) app_name: Option<String>,
title: Option<String>, pub(crate) title: Option<String>,
pid: Option<u32>, pub(crate) pid: Option<u32>,
x: i32, pub(crate) x: i32,
y: i32, pub(crate) y: i32,
width: u32, pub(crate) width: u32,
height: u32, pub(crate) height: u32,
scale_factor: f64, pub(crate) scale_factor: f64,
} }
#[derive(Clone, Deserialize, Serialize)] #[derive(Clone, Deserialize, Serialize)]
struct Region { pub(crate) struct Region {
id: String, pub(crate) id: String,
name: String, pub(crate) name: String,
description: Option<String>, pub(crate) description: Option<String>,
#[serde(rename = "type")] #[serde(rename = "type")]
region_type: String, pub(crate) region_type: String,
bbox_image: [f64; 4], pub(crate) bbox_image: [f64; 4],
bbox_source: Option<[f64; 4]>, pub(crate) bbox_source: Option<[f64; 4]>,
bbox_screen: [f64; 4], pub(crate) bbox_screen: [f64; 4],
#[serde(rename = "scaleFactor")] #[serde(rename = "scaleFactor")]
scale_factor: f64, pub(crate) scale_factor: f64,
} }
#[derive(Clone, Deserialize, Serialize)] #[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AnnotationFile { pub(crate) struct AnnotationFile {
app: String, pub(crate) app: String,
screenshot_path: String, pub(crate) screenshot_path: String,
screenshot_width: u32, pub(crate) screenshot_width: u32,
screenshot_height: u32, pub(crate) screenshot_height: u32,
scale_factor: f64, pub(crate) scale_factor: f64,
source: Option<CaptureSource>, pub(crate) source: Option<CaptureSource>,
regions: Vec<Region>, pub(crate) regions: Vec<Region>,
created_at: String, pub(crate) created_at: String,
updated_at: String, pub(crate) updated_at: String,
} }
fn data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> { pub(crate) fn data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
@ -90,7 +113,7 @@ fn data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(dir) Ok(dir)
} }
fn regions_path(app: &tauri::AppHandle) -> Result<PathBuf, String> { pub(crate) fn regions_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = data_dir(app)?.join("regions"); let dir = data_dir(app)?.join("regions");
fs::create_dir_all(&dir).map_err(|error| error.to_string())?; fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
Ok(dir.join("wechat.json")) 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<CaptureSource> {
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<CaptureSource>);
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<CaptureSource> {
Vec::new()
}
#[cfg(target_os = "windows")]
pub(crate) fn capture_source_from_hwnd(
hwnd: windows_sys::Win32::Foundation::HWND,
) -> Option<CaptureSource> {
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<WinPhysicalRect, String> {
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::<RECT>() 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<String> {
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<WinScreenMatch, String> {
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<CaptureResult, String> {
let hwnd_value = hwnd_text
.parse::<usize>()
.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<CaptureResult, String> {
Err("Windows window capture is only available on Windows".to_string())
}
fn emit_agent_log(app: &tauri::AppHandle, level: &str, message: impl Into<String>) { fn emit_agent_log(app: &tauri::AppHandle, level: &str, message: impl Into<String>) {
let _ = app.emit( let _ = app.emit(
"agent-log", "agent-log",
@ -449,6 +821,20 @@ async fn list_capture_sources() -> Result<Vec<CaptureSource>, 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) Ok(sources)
}) })
.await .await
@ -559,18 +945,112 @@ async fn capture_source(app: tauri::AppHandle, source_id: String) -> Result<Capt
); );
} }
if let Some(hwnd_text) = source_id.strip_prefix("win-window:") {
let screen_list_ms = screen_list_started_at.elapsed().as_millis();
return capture_windows_window(
hwnd_text,
screenshot_path,
screen_list_ms,
total_started_at,
);
}
Err("unsupported capture source".to_string()) Err("unsupported capture source".to_string())
}) })
.await .await
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
} }
#[tauri::command]
fn read_screenshot_bytes(app: tauri::AppHandle, path: String) -> Result<Vec<u8>, 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<PathBuf, String> {
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::<Vec<_>>();
let destination = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
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] #[tauri::command]
fn save_regions(app: tauri::AppHandle, annotation: AnnotationFile) -> Result<String, String> { fn save_regions(app: tauri::AppHandle, annotation: AnnotationFile) -> Result<String, String> {
let path = regions_path(&app)?; write_annotation_file_atomic(&app, &annotation).map(|path| path.to_string_lossy().to_string())
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())
} }
#[tauri::command] #[tauri::command]
@ -587,59 +1067,120 @@ fn load_regions(app: tauri::AppHandle) -> Result<Option<AnnotationFile>, String>
} }
#[tauri::command] #[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('/') { let route = if route.starts_with('/') {
route route
} else { } else {
format!("/{route}") 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::<String>()
);
if let Some(window) = app.get_webview_window(&label) { if route_path == "/annotate" {
if route == "/window/engine-workbench" { if let Some(window) = app.get_webview_window(&window_label) {
window let _ = window.close();
.set_fullscreen(false)
.map_err(|error| error.to_string())?;
window.maximize().map_err(|error| error.to_string())?;
} }
} 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())?; window.set_focus().map_err(|error| error.to_string())?;
return Ok(()); return Ok(());
} }
let title = match route.as_str() { let mut window_config = app
"/annotate" => "屏幕区域标注", .config()
"/window/settings" => "设置", .app
"/window/engine-workbench" => "微信引擎工作台", .windows
"/window/engine-logs" => "分身引擎日志", .iter()
"/window/knowledge-create" => "新增知识库", .find(|window| window.label == template_label)
"/window/knowledge-detail" => "知识库详情", .cloned()
"/window/skill-create" => "skill技能详情", .ok_or_else(|| format!("popup window route is not configured: {route_path}"))?;
"/window/employee-detail" => "员工蒸馏详情", window_config.label = window_label;
"/window/distill-start" => "开始蒸馏", window_config.url = WebviewUrl::App(format!("index.html#{route}").into());
_ => "二级窗口", window_config.visible = false;
};
let mut builder = WebviewWindowBuilder::new( let _window = WebviewWindowBuilder::from_config(&app, &window_config)
&app, .map_err(|error| error.to_string())?
label, .on_page_load(move |window, payload| {
WebviewUrl::App(format!("index.html#{route}").into()), if matches!(payload.event(), PageLoadEvent::Finished) {
) if let Err(error) = apply_native_window_corner(&window) {
.title(title) eprintln!("failed to apply native popup corner: {error}");
.inner_size(1080.0, 800.0) }
.decorations(false) if let Err(error) = window.center() {
.transparent(true) eprintln!("failed to center loaded popup window: {error}");
.resizable(true); }
if let Err(error) = window.show() {
if route == "/annotate" { eprintln!("failed to show loaded popup window: {error}");
builder = builder.maximized(true).fullscreen(true); }
} else if route == "/window/engine-workbench" { if let Err(error) = window.set_focus() {
builder = builder eprintln!("failed to focus loaded popup window: {error}");
.inner_size(1440.0, 920.0) }
.maximized(true) }
.fullscreen(false); })
} .build()
.map_err(|error| error.to_string())?;
builder.build().map_err(|error| error.to_string())?;
Ok(()) Ok(())
} }
@ -649,6 +1190,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(AgentProcess(Mutex::new(None))) .manage(AgentProcess(Mutex::new(None)))
.manage(VisionStreamProcess(Mutex::new(None))) .manage(VisionStreamProcess(Mutex::new(None)))
.manage(window_capture::OverlayState::default())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.setup(|app| { .setup(|app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
@ -658,19 +1200,39 @@ pub fn run() {
.build(), .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(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
close_current_window,
exit_application,
capture_source, capture_source,
capture_screen, capture_screen,
list_capture_sources, list_capture_sources,
load_regions, load_regions,
open_popup_window, open_popup_window,
read_screenshot_bytes,
save_regions, save_regions,
start_agent, start_agent,
start_vision_stream, start_vision_stream,
stop_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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,160 @@
"macOSPrivateApi": true, "macOSPrivateApi": true,
"windows": [ "windows": [
{ {
"label": "main",
"url": "index.html",
"title": "微信AI助手", "title": "微信AI助手",
"width": 500, "width": 500,
"height": 900, "height": 900,
"minWidth": 500, "minWidth": 500,
"minHeight": 900, "minHeight": 900,
"decorations": false, "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, "resizable": true,
"fullscreen": false "fullscreen": false
} }

View File

@ -1,8 +1,39 @@
import { useEffect, useState } from "react"; import { Component, useEffect, useState } from "react";
import MainShell from "./components/MainShell"; import MainShell from "./components/MainShell";
import AnnotationPage from "./pages/AnnotationPage"; import AnnotationPage from "./pages/AnnotationPage";
import SecondaryWindow from "./windows/SecondaryWindow"; import SecondaryWindow from "./windows/SecondaryWindow";
function AppErrorFallback({ error }) {
return (
<div className="grid min-h-dvh place-items-center bg-red-950 p-6 text-white">
<div className="w-full max-w-3xl rounded-[18px] border border-red-300 bg-white p-6 text-red-950 shadow-2xl">
<div className="text-[20px] font-black">应用渲染失败</div>
<div className="mt-4 space-y-2 font-mono text-[12px] leading-5">
<div>href: {window.location.href}</div>
<div>hash: {window.location.hash}</div>
<div>error: {String(error?.message || error)}</div>
</div>
</div>
</div>
);
}
class AppErrorBoundary extends Component {
state = { error: null };
componentDidCatch(error) {
this.setState({ error });
}
render() {
if (this.state.error) {
return <AppErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
function App() { function App() {
const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light"); const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light");
const [activeTab, setActiveTab] = useState("clone"); const [activeTab, setActiveTab] = useState("clone");
@ -27,6 +58,20 @@ function App() {
return () => window.removeEventListener("storage", onStorage); 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(() => { useEffect(() => {
const onHashChange = () => setRoute(window.location.hash.replace("#", "") || "/"); const onHashChange = () => setRoute(window.location.hash.replace("#", "") || "/");
window.addEventListener("hashchange", onHashChange); window.addEventListener("hashchange", onHashChange);
@ -37,30 +82,32 @@ function App() {
const isAnnotationRoute = route === "/annotate"; const isAnnotationRoute = route === "/annotate";
return ( return (
<div className="min-h-dvh overflow-hidden rounded-[12px] bg-page text-text"> <AppErrorBoundary>
{isAnnotationRoute ? ( <div className="min-h-dvh overflow-hidden rounded-[8px] bg-page text-text">
<AnnotationPage /> {isAnnotationRoute ? (
) : isWindowRoute ? ( <AnnotationPage />
<SecondaryWindow ) : isWindowRoute ? (
route={route} <SecondaryWindow
theme={theme} route={route}
setTheme={setTheme} theme={theme}
settingSection={settingSection} setTheme={setTheme}
setSettingSection={setSettingSection} settingSection={settingSection}
/> setSettingSection={setSettingSection}
) : ( />
<MainShell ) : (
activeTab={activeTab} <MainShell
setActiveTab={setActiveTab} activeTab={activeTab}
theme={theme} setActiveTab={setActiveTab}
setTheme={setTheme} theme={theme}
activeKnowledge={activeKnowledge} setTheme={setTheme}
setActiveKnowledge={setActiveKnowledge} activeKnowledge={activeKnowledge}
activeSkill={activeSkill} setActiveKnowledge={setActiveKnowledge}
setActiveSkill={setActiveSkill} activeSkill={activeSkill}
/> setActiveSkill={setActiveSkill}
)} />
</div> )}
</div>
</AppErrorBoundary>
); );
} }

View File

@ -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 (
<div className="panel flex min-h-[460px] flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-line px-4 py-3">
<div>
<h3 className="text-[14px] font-black">AI 对话测试</h3>
<p className="mt-1 text-[11px] text-muted">使用当前提示词和知识授权验证智能体回复</p>
</div>
<span className={`text-[11px] font-bold ${streaming ? "text-warning" : tested ? "text-primary" : "text-muted"}`}>
{streaming ? "生成中" : tested ? "测试通过" : "等待对话"}
</span>
</div>
<div className="min-h-[300px] flex-1 space-y-4 overflow-y-auto bg-surface2 p-4" aria-live="polite" aria-label="智能体测试对话记录">
{messages.map((message, index) => (
<div key={message.id} className={`flex items-end gap-2 ${message.role === "user" ? "flex-row-reverse" : ""}`}>
<div className={`grid h-7 w-7 shrink-0 place-items-center rounded-full text-[10px] font-black ${message.role === "user" ? "bg-primary text-white" : "border border-line bg-surface text-primary"}`}>
{message.role === "user" ? "我" : "AI"}
</div>
<div className={`max-w-[78%] whitespace-pre-wrap rounded-[8px] px-3 py-2.5 text-[13px] leading-6 ${message.role === "user" ? "bg-primary text-white" : "border border-line bg-surface text-text"}`}>
{message.files?.length ? (
<div className="mb-2 grid gap-1.5">
{message.files.map((file) => (
<div key={`${message.id}-${file.name}`} className={`flex items-center gap-2 rounded-[6px] px-2 py-1 text-[11px] ${message.role === "user" ? "bg-white/15" : "bg-surface2"}`}>
<FileText size={13} />
<span className="max-w-[220px] truncate">{file.name}</span>
<span className="opacity-70">{file.size}</span>
</div>
))}
</div>
) : null}
{message.content}
{streaming && index === messages.length - 1 && message.role === "assistant" ? <span className="ml-0.5 inline-block animate-pulse text-primary">|</span> : null}
</div>
</div>
))}
<div ref={scrollAnchor} />
</div>
<div className="border-t border-line bg-surface px-3 py-3">
{attachments.length ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file) => (
<div key={`${file.name}:${file.size}:${file.lastModified}`} className="flex max-w-[240px] items-center gap-2 rounded-[8px] border border-line bg-surface2 px-2.5 py-1.5 text-[11px] text-text">
<FileText className="shrink-0 text-primary" size={14} />
<span className="truncate">{file.name}</span>
<button className="text-muted transition hover:text-error" aria-label={`移除 ${file.name}`} onClick={() => setAttachments((current) => current.filter((item) => item !== file))} disabled={streaming}>
<X size={13} />
</button>
</div>
))}
</div>
) : null}
<div className="flex items-end gap-2 rounded-[10px] border border-lineStrong bg-surface2 p-2 shadow-sm transition focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--primary-soft)]">
{allowFiles ? (
<>
<input ref={fileInput} className="hidden" type="file" multiple accept=".pdf,.docx,.ppt,.pptx,.txt,.md,.csv,.json" onChange={selectFiles} />
<button className="grid h-9 w-9 shrink-0 place-items-center rounded-[8px] text-muted transition hover:bg-surface hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50" aria-label="上传文件" title="上传文件" onClick={() => fileInput.current?.click()} disabled={streaming || attachments.length >= MAX_FILES}>
<Paperclip size={17} />
</button>
</>
) : null}
<textarea rows={2} className="max-h-[120px] min-h-[48px] flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-text outline-none placeholder:text-subtle disabled:cursor-not-allowed" aria-label="测试消息" placeholder={allowFiles ? "输入消息,或上传文件后提问..." : "输入客户消息..."} value={input} onChange={(event) => setInput(event.target.value)} onKeyDown={handleInputKeyDown} disabled={streaming} />
<button className="grid h-9 w-9 shrink-0 place-items-center rounded-[8px] bg-primary text-white shadow-sm transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:bg-lineStrong disabled:text-subtle disabled:shadow-none" aria-label="发送测试消息" title="发送" onClick={() => void sendMessage()} disabled={streaming || (!input.trim() && !attachments.length)}>
<Send size={16} />
</button>
</div>
<div className="mt-1.5 flex items-center justify-between px-1 text-[10px] text-subtle">
<span>{allowFiles ? `最多上传 ${MAX_FILES} 个文件,每个不超过 20 MB` : "Enter 发送 · Shift + Enter 换行"}</span>
<span>{input.length} </span>
</div>
{error ? <p className="mt-2 px-1 text-[11px] text-error">{error}</p> : null}
</div>
</div>
);
}

View File

@ -10,13 +10,13 @@ export default function MainShell(props) {
const activeTitle = tabs.find((tab) => tab.id === props.activeTab)?.label ?? "AI微信分身助手"; const activeTitle = tabs.find((tab) => tab.id === props.activeTab)?.label ?? "AI微信分身助手";
const content = { const content = {
clone: <ClonePage />, clone: <ClonePage />,
knowledge: <KnowledgePage active={props.activeKnowledge} setActive={props.setActiveKnowledge} />, knowledge: <KnowledgePage />,
skills: <SkillsPage active={props.activeSkill} setActive={props.setActiveSkill} />, skills: <SkillsPage active={props.activeSkill} setActive={props.setActiveSkill} />,
distill: <DistillPage />, distill: <DistillPage />,
}[props.activeTab]; }[props.activeTab];
return ( return (
<div className="app-window flex h-dvh w-full flex-col overflow-hidden rounded-[12px]"> <div className="app-window flex h-dvh w-full flex-col overflow-hidden rounded-[8px]">
<TitleBar title={activeTitle} theme={props.theme} setTheme={props.setTheme} /> <TitleBar title={activeTitle} theme={props.theme} setTheme={props.setTheme} />
<main className="relative flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-5">{content}</main> <main className="relative flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-5">{content}</main>
<BottomTabs activeTab={props.activeTab} setActiveTab={props.setActiveTab} /> <BottomTabs activeTab={props.activeTab} setActiveTab={props.setActiveTab} />

View File

@ -0,0 +1,398 @@
import { useEffect, useRef } from "react";
const MIN_NODE_COUNT = 20;
const TARGET_NODE_COUNT = 30;
const MAX_NODE_COUNT = 40;
const NODE_COUNT_INTERVAL_SECONDS = 0.7;
const NODE_ENTER_DURATION_MS = 600;
const MIN_NODE_EXIT_DURATION_MS = 1000;
const MAX_NODE_EXIT_DURATION_MS = 3000;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const STATUS_LABELS = {
idle: "未启动",
running: "运行中",
error: "异常错误",
};
function seededValue(nodeId, bucket) {
let value = Math.imul(nodeId + 17, 0x45d9f3b) ^ Math.imul(bucket + 31, 0x27d4eb2d);
value ^= value >>> 16;
return value >>> 0;
}
function updateNodeStatuses(nodes, engineStatus, bucket) {
for (const node of nodes) {
if (engineStatus === "idle") {
node.status = "idle";
continue;
}
const value = seededValue(node.id, bucket);
if (engineStatus === "error") {
node.status = value % 3 === 0 ? "running" : "error";
} else {
node.status = value % 11 === 0 ? "error" : "running";
}
}
}
function createNode(id, layoutIndex, phase = "active", transitionStartedAt = 0) {
const entering = phase === "entering";
return {
id,
layoutIndex,
status: "idle",
x: 0,
y: 0,
phase,
transitionStartedAt,
transitionDurationMs: entering ? NODE_ENTER_DURATION_MS : 0,
transitionFromOpacity: entering ? 0 : 1,
transitionFromScale: entering ? 0.35 : 1,
opacity: entering ? 0 : 1,
scale: entering ? 0.35 : 1,
};
}
function nextNodeCount(currentCount, randomValue) {
if (currentCount <= MIN_NODE_COUNT) return MIN_NODE_COUNT + 1;
if (currentCount >= MAX_NODE_COUNT) return MAX_NODE_COUNT - 1;
if (currentCount === TARGET_NODE_COUNT) {
return currentCount + (randomValue < 0.5 ? -1 : 1);
}
const towardTarget = currentCount < TARGET_NODE_COUNT ? 1 : -1;
return currentCount + (randomValue < 0.72 ? towardTarget : -towardTarget);
}
function randomExitDuration(randomValue) {
return MIN_NODE_EXIT_DURATION_MS
+ randomValue * (MAX_NODE_EXIT_DURATION_MS - MIN_NODE_EXIT_DURATION_MS);
}
function updateNodeTransition(node, now) {
if (node.phase === "active") {
node.opacity = 1;
node.scale = 1;
return;
}
const duration = Math.max(node.transitionDurationMs, 1);
const progress = Math.min(Math.max((now - node.transitionStartedAt) / duration, 0), 1);
const eased = progress * progress * (3 - 2 * progress);
if (node.phase === "entering") {
node.opacity = node.transitionFromOpacity
+ (1 - node.transitionFromOpacity) * eased;
node.scale = node.transitionFromScale
+ (1 - node.transitionFromScale) * eased;
if (progress === 1) node.phase = "active";
} else {
node.opacity = node.transitionFromOpacity * (1 - eased);
node.scale = node.transitionFromScale
+ (0.45 - node.transitionFromScale) * eased;
}
}
function startNodeTransition(node, phase, now, duration) {
updateNodeTransition(node, now);
node.phase = phase;
node.transitionStartedAt = now;
node.transitionDurationMs = duration;
node.transitionFromOpacity = node.opacity;
node.transitionFromScale = node.scale;
}
function drawGlow(ctx, x, y, radius, color, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, color);
gradient.addColorStop(0.32, `${color}88`);
gradient.addColorStop(1, `${color}00`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
export default function NodeSphere({ status }) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
const context = canvas.getContext("2d");
if (!context) return undefined;
let lightTheme = true;
const colors = {
idle: "#687970",
running: "#07c160",
error: "#f5222d",
};
function syncPalette() {
const styles = getComputedStyle(canvas);
lightTheme = document.documentElement.dataset.theme !== "dark";
colors.idle = lightTheme ? "#687970" : "#94a39c";
colors.running = styles.getPropertyValue("--primary").trim() || "#07c160";
colors.error = styles.getPropertyValue("--error").trim() || "#f5222d";
}
syncPalette();
const nodes = Array.from(
{ length: TARGET_NODE_COUNT },
(_, index) => createNode(index + 1, index),
);
const escapeParticles = [];
let width = 1;
let height = 1;
let dpr = 1;
let frameId = 0;
const startedAt = performance.now();
let previousFrame = startedAt;
let statusBucket = -1;
let nodeCountBucket = 0;
let nextNodeId = TARGET_NODE_COUNT + 1;
function resize() {
const rect = canvas.getBoundingClientRect();
width = Math.max(1, rect.width);
height = Math.max(1, rect.height);
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function drawNode(node) {
if (node.opacity <= 0.01) return;
const color = colors[node.status];
const baseRadius = node.status === "idle"
? lightTheme ? 3.7 : 3.1
: lightTheme ? 4.4 : 3.8;
const radius = baseRadius * node.scale;
if (node.status !== "idle") {
const glowAlpha = node.status === "error"
? lightTheme ? 0.3 : 0.42
: lightTheme ? 0.22 : 0.3;
drawGlow(
context,
node.x,
node.y,
radius * 3.8,
color,
glowAlpha * node.opacity,
);
}
context.save();
context.globalAlpha = node.opacity;
context.strokeStyle = node.status === "idle"
? lightTheme ? "rgba(63,84,74,.62)" : "rgba(148,163,156,.42)"
: `${color}${lightTheme ? "cc" : "99"}`;
context.lineWidth = lightTheme ? 1.05 : 0.8;
context.beginPath();
context.arc(node.x, node.y, radius + 2.4 * node.scale, 0, Math.PI * 2);
context.stroke();
context.fillStyle = color;
context.beginPath();
context.arc(node.x, node.y, radius, 0, Math.PI * 2);
context.fill();
context.fillStyle = "rgba(255,255,255,.82)";
context.beginPath();
context.arc(node.x - radius * 0.28, node.y - radius * 0.32, 0.9 * node.scale, 0, Math.PI * 2);
context.fill();
context.restore();
}
let logicalNodeCount = TARGET_NODE_COUNT;
function adjustNodeCount(now) {
const desiredCount = nextNodeCount(logicalNodeCount, Math.random());
if (desiredCount > logicalNodeCount) {
let node = null;
for (let index = nodes.length - 1; index >= 0; index -= 1) {
if (nodes[index].phase !== "exiting") continue;
node = nodes[index];
startNodeTransition(node, "entering", now, NODE_ENTER_DURATION_MS);
break;
}
if (!node) {
let layoutIndex = 0;
while (nodes.some((item) => item.layoutIndex === layoutIndex)) layoutIndex += 1;
node = createNode(nextNodeId, layoutIndex, "entering", now);
nextNodeId += 1;
nodes.push(node);
}
updateNodeStatuses([node], status, Math.max(statusBucket, 0));
} else {
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index];
if (node.phase === "exiting") continue;
const duration = randomExitDuration(Math.random());
startNodeTransition(node, "exiting", now, duration);
break;
}
}
logicalNodeCount = desiredCount;
canvas.setAttribute(
"aria-label",
`${logicalNodeCount} 个引擎节点:${STATUS_LABELS[status]}`,
);
}
function drawFrame(now) {
const delta = Math.min((now - previousFrame) / 1000, 0.05);
previousFrame = now;
const elapsed = (now - startedAt) / 1000;
const bucket = status === "running" ? Math.floor(elapsed / 3) : Math.floor(elapsed / 2.2);
if (bucket !== statusBucket) {
statusBucket = bucket;
updateNodeStatuses(nodes, status, bucket);
}
if (status === "running") {
const currentNodeCountBucket = Math.floor(elapsed / NODE_COUNT_INTERVAL_SECONDS);
if (currentNodeCountBucket !== nodeCountBucket) {
nodeCountBucket = currentNodeCountBucket;
adjustNodeCount(now);
}
}
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index];
updateNodeTransition(node, now);
if (node.phase === "exiting" && node.opacity <= 0.01) {
nodes.splice(index, 1);
}
}
context.clearRect(0, 0, width, height);
const centerX = width / 2;
const centerY = height / 2 - 5;
const radius = Math.min(width, height) * 0.36;
const ambient = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius * 1.35);
ambient.addColorStop(0, lightTheme ? "rgba(7,193,96,.14)" : "rgba(7,193,96,.075)");
ambient.addColorStop(0.62, lightTheme ? "rgba(7,193,96,.05)" : "rgba(7,193,96,.025)");
ambient.addColorStop(1, "rgba(7,193,96,0)");
context.fillStyle = ambient;
context.beginPath();
context.arc(centerX, centerY, radius * 1.35, 0, Math.PI * 2);
context.fill();
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
const angle = node.layoutIndex * GOLDEN_ANGLE + elapsed * 0.055;
const distance = radius * (0.24 + 0.74 * Math.sqrt((node.layoutIndex + 1) / MAX_NODE_COUNT));
node.x = centerX + Math.cos(angle) * distance * 1.15 + Math.sin(elapsed + node.layoutIndex) * 1.4;
node.y = centerY + Math.sin(angle) * distance * 0.87 + Math.cos(elapsed * 0.8 + node.layoutIndex) * 1.4;
}
context.save();
context.globalCompositeOperation = lightTheme ? "source-over" : "lighter";
for (let first = 0; first < nodes.length; first += 1) {
const a = nodes[first];
for (let second = first + 1; second < nodes.length; second += 1) {
const b = nodes[second];
const linkOpacity = Math.min(a.opacity, b.opacity);
if (linkOpacity <= 0.01) continue;
const distance = Math.hypot(a.x - b.x, a.y - b.y);
const limit = radius * 0.58;
if (distance >= limit) continue;
const danger = a.status === "error" || b.status === "error";
const idle = a.status === "idle" && b.status === "idle";
const strength = 1 - distance / limit;
const linkColor = danger
? "245,34,45"
: idle
? lightTheme ? "70,91,81" : "148,163,156"
: lightTheme ? "5,145,72" : "7,193,96";
const linkAlpha = lightTheme
? (idle ? 0.2 : 0.24) + strength * (idle ? 0.2 : 0.34)
: 0.09 + strength * (idle ? 0.1 : 0.22);
context.save();
context.globalAlpha = linkOpacity;
context.shadowColor = danger ? colors.error : idle ? colors.idle : colors.running;
context.shadowBlur = idle ? 0 : lightTheme ? 5 : 8;
context.strokeStyle = `rgba(${linkColor},${linkAlpha})`;
context.lineWidth = lightTheme ? (idle ? 0.9 : 1.1) : (idle ? 0.6 : 0.85);
context.beginPath();
context.moveTo(a.x, a.y);
context.lineTo(b.x, b.y);
context.stroke();
if (!idle) {
const progress = (elapsed * 0.27 + first * 0.113 + b.id * 0.037) % 1;
const packetX = a.x + (b.x - a.x) * progress;
const packetY = a.y + (b.y - a.y) * progress;
context.fillStyle = danger
? lightTheme ? "#d91b27" : "#ff9cab"
: lightTheme ? "#057f40" : "#b8f4d5";
context.beginPath();
context.arc(packetX, packetY, (lightTheme ? 1.05 : 0.75) + strength * 0.55, 0, Math.PI * 2);
context.fill();
}
context.restore();
}
}
context.restore();
if (status !== "idle" && escapeParticles.length < 36 && Math.random() < 0.055) {
const source = nodes[Math.floor(Math.random() * nodes.length)];
const angle = Math.atan2(source.y - centerY, source.x - centerX) + (Math.random() - 0.5) * 0.9;
const life = 0.7 + Math.random() * 0.9;
escapeParticles.push({
x: source.x,
y: source.y,
velocityX: Math.cos(angle) * (8 + Math.random() * 16),
velocityY: Math.sin(angle) * (8 + Math.random() * 16),
life,
maxLife: life,
color: colors[source.status],
});
}
for (let index = escapeParticles.length - 1; index >= 0; index -= 1) {
const particle = escapeParticles[index];
particle.x += particle.velocityX * delta;
particle.y += particle.velocityY * delta;
particle.life -= delta;
if (particle.life <= 0) {
escapeParticles.splice(index, 1);
continue;
}
context.globalAlpha = particle.life / particle.maxLife;
context.fillStyle = particle.color;
context.beginPath();
context.arc(particle.x, particle.y, 0.8, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
for (const node of nodes) drawNode(node);
frameId = requestAnimationFrame(drawFrame);
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const themeObserver = new MutationObserver(syncPalette);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
frameId = requestAnimationFrame(drawFrame);
return () => {
observer.disconnect();
themeObserver.disconnect();
cancelAnimationFrame(frameId);
};
}, [status]);
return (
<div className="node-sphere" data-engine-status={status}>
<canvas
ref={canvasRef}
className="node-sphere-canvas"
aria-label={`${TARGET_NODE_COUNT} 个引擎节点:${STATUS_LABELS[status]}`}
/>
</div>
);
}

View File

@ -1,11 +1,16 @@
export default function Segmented({ items, active, onChange }) { export default function Segmented({ items, active, onChange }) {
return ( return (
<div className="flex gap-2 overflow-x-auto rounded-[8px] border border-line bg-surface p-2"> <div
className="grid w-full gap-1 rounded-[8px] border border-line bg-surface p-1.5"
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
>
{items.map((item) => ( {items.map((item) => (
<button <button
key={item} key={item}
className={`shrink-0 rounded-full px-8 py-1.5 text-[13px] transition ${ type="button"
active === item ? "bg-primary text-white shadow-[0_10px_24px_rgba(7,193,96,.22)]" : "text-muted hover:bg-surface2" aria-pressed={active === item}
className={`min-w-0 whitespace-nowrap rounded-full px-2 py-1.5 text-[13px] font-semibold transition ${
active === item ? "bg-primary text-white shadow-[0_8px_18px_rgba(7,193,96,.2)]" : "text-muted hover:bg-surface2"
}`} }`}
onClick={() => onChange(item)} onClick={() => onChange(item)}
> >

View File

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export default function Terminal({ title, lines, tall = false, onClear }) { export default function Terminal({ title, lines, tall = false, onClear, onViewAll }) {
const scrollRef = useRef(null); const scrollRef = useRef(null);
useEffect(() => { useEffect(() => {
@ -30,11 +30,14 @@ export default function Terminal({ title, lines, tall = false, onClear }) {
<div className={`terminal ${tall ? "min-h-[520px] basis-[650px]" : "min-h-[220px] basis-[250px]"} flex flex-1 flex-col`}> <div className={`terminal ${tall ? "min-h-[520px] basis-[650px]" : "min-h-[220px] basis-[250px]"} flex flex-1 flex-col`}>
<div className="mb-3 flex shrink-0 items-center justify-between"> <div className="mb-3 flex shrink-0 items-center justify-between">
<h3 className="text-[13px] font-black text-[#d7ffe4]">{title}</h3> <h3 className="text-[13px] font-black text-[#d7ffe4]">{title}</h3>
<button className="cursor-pointer border-0 bg-transparent p-0 text-[11px] font-bold text-[#7ea58b]" onClick={onClear}>清理日志</button> <div className="flex items-center gap-3">
{onViewAll ? <button className="cursor-pointer border-0 bg-transparent p-0 text-[11px] font-bold text-[#7ea58b]" onClick={onViewAll}>查看完整日志</button> : null}
<button className="cursor-pointer border-0 bg-transparent p-0 text-[11px] font-bold text-[#7ea58b]" onClick={onClear}>清理日志</button>
</div>
</div> </div>
<div ref={scrollRef} className="scrollbar-hidden relative min-h-0 flex-1 overflow-y-auto font-mono text-[12px] leading-5"> <div ref={scrollRef} className="scrollbar-hidden relative min-h-0 flex-1 overflow-y-auto font-mono text-[12px] leading-5">
{lines.length ? ( {lines.length ? (
<div className="space-y-2"> <div className="selectable-text space-y-2">
{lines.map((line, index) => { {lines.map((line, index) => {
const level = typeof line === "string" ? "info" : line.level; const level = typeof line === "string" ? "info" : line.level;
const message = typeof line === "string" ? line : line.message; const message = typeof line === "string" ? line : line.message;

View File

@ -1,6 +1,6 @@
import { LogOut, RotateCcw } from "lucide-react"; import { LogOut, RotateCcw, Settings } from "lucide-react";
import ThemeSwitch from "./ThemeSwitch"; import ThemeSwitch from "./ThemeSwitch";
import { closeWindow, startWindowDrag } from "../utils/navigation"; import { closeWindow, exitApplication, openWindow, startWindowDrag } from "../utils/navigation";
export default function TitleBar({ title, theme, setTheme, compact = false, onClose }) { export default function TitleBar({ title, theme, setTheme, compact = false, onClose }) {
return ( return (
@ -20,8 +20,9 @@ export default function TitleBar({ title, theme, setTheme, compact = false, onCl
<ThemeSwitch theme={theme} setTheme={setTheme} /> <ThemeSwitch theme={theme} setTheme={setTheme} />
{!compact ? ( {!compact ? (
<> <>
<button className="icon-btn" onClick={() => openWindow("/window/settings")} aria-label="设置"><Settings size={16} strokeWidth={2.5} /></button>
<button className="icon-btn" aria-label="重启"><RotateCcw size={16} strokeWidth={2.5} /></button> <button className="icon-btn" aria-label="重启"><RotateCcw size={16} strokeWidth={2.5} /></button>
<button className="icon-btn" aria-label="退出"><LogOut size={16} strokeWidth={2.5} /></button> <button className="icon-btn" onClick={exitApplication} aria-label="退出"><LogOut size={16} strokeWidth={2.5} /></button>
</> </>
) : ( ) : (
<button className="btn-secondary h-9 px-3" onClick={onClose || closeWindow}>关闭</button> <button className="btn-secondary h-9 px-3" onClick={onClose || closeWindow}>关闭</button>

View File

@ -0,0 +1,90 @@
import { Check, ChevronRight } from "lucide-react";
export function WindowBody({ children, className = "" }) {
return <div className={`min-h-0 flex-1 overflow-y-auto p-5 ${className}`}>{children}</div>;
}
export function WindowTabs({ items, active, onChange }) {
return (
<div className="scrollbar-hidden flex gap-1 overflow-x-auto border-b border-line">
{items.map((item) => (
<button
key={item}
className={`shrink-0 border-b-2 px-4 py-3 text-[13px] font-bold transition ${
active === item ? "border-primary text-primary" : "border-transparent text-muted hover:text-text"
}`}
onClick={() => onChange(item)}
>
{item}
</button>
))}
</div>
);
}
export function WizardSteps({ items, step, onChange }) {
return (
<div className="grid gap-2 border-b border-line bg-surface2 px-5 py-3 sm:grid-flow-col sm:auto-cols-fr">
{items.map((item, index) => {
const done = index < step;
const active = index === step;
return (
<button
key={item}
className={`flex items-center gap-2 rounded-xl px-3 py-2 text-left text-[12px] font-bold ${
active ? "bg-primary text-white" : done ? "bg-[var(--primary-soft)] text-primary" : "text-muted"
}`}
onClick={() => index <= step && onChange?.(index)}
>
<span className={`grid h-6 w-6 shrink-0 place-items-center rounded-full ${active ? "bg-white/20" : "bg-surface"}`}>
{done ? <Check size={14} /> : index + 1}
</span>
<span className="truncate">{item}</span>
</button>
);
})}
</div>
);
}
export function FormRow({ label, hint, children, required = false }) {
return (
<label className="grid gap-2">
<span className="text-[12px] font-black text-muted">
{label}{required ? <span className="ml-1 text-error">*</span> : null}
</span>
{children}
{hint ? <span className="text-[11px] leading-5 text-subtle">{hint}</span> : null}
</label>
);
}
export function PageActions({ children, status }) {
return (
<div className="sticky bottom-0 z-10 mt-5 flex items-center justify-between gap-4 border-t border-line bg-page/95 px-1 py-4 backdrop-blur">
<span className="text-[12px] font-semibold text-muted">{status}</span>
<div className="flex flex-wrap justify-end gap-2">{children}</div>
</div>
);
}
export function Metric({ label, value, tone = "" }) {
return (
<div className="rounded-[14px] border border-line bg-surface2 p-4">
<div className={`text-[20px] font-black ${tone}`}>{value}</div>
<div className="mt-1 text-[11px] font-semibold text-muted">{label}</div>
</div>
);
}
export function InfoRow({ label, value, action }) {
return (
<div className="flex items-center justify-between gap-4 border-b border-line py-3 last:border-0">
<div className="min-w-0">
<div className="text-[11px] font-bold text-muted">{label}</div>
<div className="mt-1 truncate text-[13px] font-semibold">{value}</div>
</div>
{action ? <button className="btn-ghost shrink-0">{action}<ChevronRight size={14} /></button> : null}
</div>
);
}

View File

@ -13,7 +13,7 @@ function SelectTrigger({ className, children, ...props }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
className={cn( className={cn(
"flex h-[42px] w-full items-center justify-between rounded-[11px] border border-lineStrong bg-surface px-3 text-[13px] font-semibold text-text outline-none transition placeholder:text-subtle focus:border-primary disabled:cursor-not-allowed disabled:bg-surface2 disabled:text-muted", "flex h-[40px] w-full items-center justify-between rounded-[4px] border border-lineStrong bg-surface px-2.5 text-[13px] font-semibold text-text outline-none transition placeholder:text-subtle focus:border-primary disabled:cursor-not-allowed disabled:bg-surface2 disabled:text-muted",
className, className,
)} )}
{...props} {...props}
@ -53,7 +53,7 @@ function SelectContent({ className, children, position = "popper", ...props }) {
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
className={cn( className={cn(
"relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-[12px] border border-line bg-surface text-text shadow-[0_18px_52px_rgba(0,0,0,.18)] data-[state=open]:animate-in data-[state=closed]:animate-out", "relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-[4px] border border-line bg-surface text-text shadow-[0_18px_52px_rgba(0,0,0,.18)] data-[state=open]:animate-in data-[state=closed]:animate-out",
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className, className,
)} )}
@ -74,7 +74,7 @@ function SelectItem({ className, children, ...props }) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
className={cn( className={cn(
"relative flex h-9 w-full cursor-default select-none items-center rounded-[9px] py-1.5 pl-8 pr-2 text-[13px] font-semibold outline-none transition focus:bg-surface2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex h-8 w-full cursor-default select-none items-center rounded-[4px] py-1 pl-8 pr-2 text-[13px] font-semibold outline-none transition focus:bg-surface2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
{...props} {...props}
@ -89,4 +89,22 @@ function SelectItem({ className, children, ...props }) {
); );
} }
export { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue }; function SimpleSelect({ options, placeholder, ariaLabel, ...props }) {
return (
<Select {...props}>
<SelectTrigger aria-label={ariaLabel}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option) => {
const item = typeof option === "string" ? { value: option, label: option } : option;
return <SelectItem key={item.value} value={item.value} disabled={item.disabled}>{item.label}</SelectItem>;
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
export { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, SimpleSelect };

View File

@ -3,7 +3,7 @@ import { BookOpen, Briefcase, FileText, Image, MessageCircle, Users, Video, Wren
export const tabs = [ export const tabs = [
{ id: "clone", label: "微信分身", icon: MessageCircle, badge: "3" }, { id: "clone", label: "微信分身", icon: MessageCircle, badge: "3" },
{ id: "knowledge", label: "知识库", icon: BookOpen, badge: "" }, { id: "knowledge", label: "知识库", icon: BookOpen, badge: "" },
{ id: "skills", label: "skill市场", icon: Wrench, badge: "新" }, { id: "skills", label: "智能体市场", icon: Wrench, badge: "新" },
{ id: "distill", label: "员工蒸馏", icon: Users, badge: "" }, { id: "distill", label: "员工蒸馏", icon: Users, badge: "" },
]; ];
@ -65,7 +65,7 @@ export const settingSections = [
"基础配置", "基础配置",
"提示词配置", "提示词配置",
"回复规则", "回复规则",
"skill管理", "智能体管理",
"员工管理", "员工管理",
"知识库管理", "知识库管理",
"托管配置", "托管配置",

View File

@ -21,6 +21,7 @@
--warning: #fa8c16; --warning: #fa8c16;
--error: #f5222d; --error: #f5222d;
--shadow: 0 24px 70px rgba(20, 36, 27, 0.1); --shadow: 0 24px 70px rgba(20, 36, 27, 0.1);
--font-cjk-ui: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", system-ui, sans-serif;
} }
html[data-theme="dark"] { html[data-theme="dark"] {
@ -65,6 +66,31 @@ body {
height: 100%; height: 100%;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
letter-spacing: 0; letter-spacing: 0;
-webkit-user-select: none;
user-select: none;
}
body {
text-rendering: optimizeLegibility;
font-kerning: normal;
}
/* WebView2 renders sub-12px text harshly on some Windows DPI scales. */
.app-window [class~="text-[10px]"],
.app-window [class~="text-[11px]"] {
font-size: 12px !important;
line-height: 1.5;
}
input,
textarea,
[contenteditable="true"],
pre,
code,
.selectable-text {
-webkit-user-select: text;
user-select: text;
} }
button, button,
@ -73,17 +99,46 @@ textarea {
font: inherit; font: inherit;
} }
input,
textarea {
font-family: var(--font-cjk-ui);
font-synthesis: none;
font-variant-east-asian: proportional-width;
text-rendering: auto;
}
button { button {
cursor: pointer; cursor: pointer;
} }
.scrollbar-hidden { * {
scrollbar-width: none; scrollbar-width: thin;
-ms-overflow-style: none; scrollbar-color: transparent transparent;
} }
.scrollbar-hidden::-webkit-scrollbar { *::-webkit-scrollbar {
display: none; width: 7px;
height: 7px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: transparent;
background-clip: padding-box;
}
*.scrollbar-active {
scrollbar-color: rgba(112, 126, 119, 0.46) transparent;
}
*.scrollbar-active::-webkit-scrollbar-thumb {
background: rgba(112, 126, 119, 0.46);
background-clip: padding-box;
} }
@layer components { @layer components {
@ -112,7 +167,7 @@ button {
} }
.panel { .panel {
@apply rounded-[18px] border border-line bg-surface shadow-panel; @apply rounded-[6px] border border-line bg-surface shadow-panel;
} }
.engine-panel { .engine-panel {
@ -174,6 +229,10 @@ button {
@apply grid h-10 w-10 place-items-center rounded-full border border-line bg-surface text-[13px] font-black text-muted shadow-[0_10px_30px_rgba(0,0,0,.04)] transition hover:bg-surface2 hover:text-primary; @apply grid h-10 w-10 place-items-center rounded-full border border-line bg-surface text-[13px] font-black text-muted shadow-[0_10px_30px_rgba(0,0,0,.04)] transition hover:bg-surface2 hover:text-primary;
} }
.icon-action.is-active {
color: var(--primary);
}
.icon-box, .icon-box,
.icon-box-sm { .icon-box-sm {
@apply grid shrink-0 place-items-center rounded-[12px] border border-line bg-surface2 text-[12px] font-black text-muted; @apply grid shrink-0 place-items-center rounded-[12px] border border-line bg-surface2 text-[12px] font-black text-muted;
@ -223,7 +282,7 @@ button {
background: background:
radial-gradient(circle at 12% 0%, rgba(7, 193, 96, 0.08), transparent 30%), radial-gradient(circle at 12% 0%, rgba(7, 193, 96, 0.08), transparent 30%),
linear-gradient(145deg, #0b100d, #101814 52%, #090f0c); linear-gradient(145deg, #0b100d, #101814 52%, #090f0c);
@apply overflow-hidden rounded-[10px] border border-line p-4 shadow-panel; @apply overflow-hidden rounded-[6px] border border-line p-4 shadow-panel;
} }
.terminal-line-info { .terminal-line-info {
@ -247,7 +306,7 @@ button {
} }
.list-card { .list-card {
@apply flex items-start justify-between gap-3 rounded-[8px] border border-line bg-surface p-3 shadow-panel; @apply flex items-start justify-between gap-3 rounded-[6px] border border-line bg-surface p-3 shadow-panel;
} }
.tag { .tag {
@ -303,11 +362,13 @@ button {
} }
.input-like { .input-like {
@apply flex h-[42px] w-full items-center rounded-[11px] border border-lineStrong bg-surface px-3 text-[13px] font-semibold text-text outline-none transition placeholder:text-subtle focus:border-primary disabled:cursor-not-allowed disabled:bg-surface2 disabled:text-muted; @apply flex h-[40px] w-full items-center rounded-[4px] border border-lineStrong bg-surface px-2.5 text-[14px] font-medium leading-6 text-text outline-none transition placeholder:text-subtle focus:border-primary disabled:cursor-not-allowed disabled:bg-surface2 disabled:text-muted;
font-family: var(--font-cjk-ui);
} }
.editor { .editor {
@apply min-h-[160px] w-full resize-none overflow-y-auto rounded-[8px] border border-lineStrong bg-surface p-2 font-mono text-[13px] leading-6 text-text outline-none transition focus:border-primary disabled:text-muted; @apply min-h-[160px] w-full resize-none overflow-y-auto rounded-[4px] border border-lineStrong bg-surface px-3 py-2 text-[14px] font-normal leading-6 text-text outline-none transition focus:border-primary disabled:text-muted;
font-family: var(--font-cjk-ui);
} }
.mini-switch { .mini-switch {
@ -327,54 +388,155 @@ button {
} }
} }
.engine-sphere { .startup-check-progress {
position: relative; height: 4px;
width: 190px; overflow: hidden;
height: 190px; background: #14251a;
border-radius: 999px; }
.startup-check-progress-bar {
height: 100%;
border-radius: 0 999px 999px 0;
background: #45e77d;
box-shadow: 0 0 10px rgba(69, 231, 125, 0.55);
transition: width 260ms ease;
}
.startup-check-terminal {
border: 1px solid #1a3c27;
border-radius: 8px;
background: #030b06;
box-shadow: inset 0 0 35px rgba(42, 190, 92, 0.04);
}
.startup-check-body {
min-height: 210px;
padding: 18px;
background: background:
radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.95), transparent 12%), repeating-linear-gradient(0deg, rgba(126, 255, 168, 0.014) 0, rgba(126, 255, 168, 0.014) 1px, transparent 1px, transparent 4px),
radial-gradient(circle at 42% 36%, rgba(255, 255, 255, 0.38), transparent 20%), #030b06;
radial-gradient(circle at 62% 72%, rgba(0, 0, 0, 0.22), transparent 34%), color: #c8ecd3;
linear-gradient(145deg, #10d978, #07c160 48%, #047a40); font-family: Consolas, "Microsoft YaHei UI", "Microsoft YaHei", ui-monospace, monospace;
box-shadow: font-size: 14px;
inset -22px -28px 54px rgba(0, 0, 0, 0.24), line-height: 1.7;
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, .startup-check-intro {
.engine-sphere::after { margin: 0 0 18px;
content: ""; color: #789182;
}
.startup-check-line {
display: flex;
min-height: 30px;
align-items: center;
gap: 10px;
}
.startup-check-leader {
min-width: 28px;
flex: 1;
align-self: center;
border-bottom: 2px dotted #31513c;
}
.startup-check-checking {
color: #e5c55b;
}
.startup-check-passed {
color: #45e77d;
}
.startup-check-failed,
.startup-check-error {
color: #ff6b74;
}
.startup-check-summary,
.startup-check-error {
margin: 16px 0 0;
border-top: 1px solid #173321;
padding-top: 12px;
font-weight: 700;
}
.startup-check-summary {
color: #45e77d;
}
.startup-log-cursor {
color: #45e77d;
animation: terminal-blink 0.8s steps(1) infinite;
}
@keyframes startup-log-enter {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes terminal-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.startup-log-enter,
.startup-log-cursor {
animation: none;
}
.startup-check-progress-bar {
transition: none;
}
}
/* Business-page cards use one compact radius even when a local utility is present. */
.app-window .panel,
.app-window .terminal,
.app-window .list-card,
.app-window [class~="rounded-[18px]"],
.app-window [class~="rounded-[14px]"],
.app-window [class~="rounded-[12px]"],
.app-window [class~="rounded-xl"] {
border-radius: 6px !important;
}
.engine-visual {
position: relative;
isolation: isolate;
overflow: visible;
padding-top: clamp(220px, 31vh, 300px);
}
.engine-visual > :not(.node-sphere) {
position: relative;
z-index: 1;
}
.node-sphere {
position: absolute; position: absolute;
inset: 18px; z-index: 0;
border-radius: 999px; top: -48px;
border: 1px solid rgba(255, 255, 255, 0.24); left: 50%;
transform: rotate(-24deg); width: 100vw;
height: 42vh;
min-height: 280px;
transform: translateX(-50%);
pointer-events: none;
} }
.engine-sphere::after { .node-sphere-canvas {
inset: 42px 18px; display: block;
opacity: 0.68; width: 100%;
transform: rotate(22deg); height: 100%;
}
@keyframes floatSphere {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
50% {
transform: translate3d(0, -12px, 0) rotate(8deg);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.engine-sphere {
width: 154px;
height: 154px;
}
.fab { .fab {
right: 22px; right: 22px;

442
src/overlay.css Normal file
View File

@ -0,0 +1,442 @@
:root {
color: #f8fafc;
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body,
#overlay-root {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
body {
user-select: none;
}
button,
input,
textarea {
font: inherit;
}
input,
textarea {
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
font-size: 14px;
line-height: 24px;
font-synthesis: none;
font-variant-east-asian: proportional-width;
text-rendering: auto;
}
#overlay-root {
position: fixed;
inset: 0;
cursor: default;
touch-action: none;
}
#overlay-root[data-mode="window_select"],
#overlay-root[data-mode="annotation"][data-drawing="true"] {
cursor: crosshair;
}
.selection-scrim {
position: absolute;
inset: 0;
z-index: 10;
width: 100%;
height: 100%;
pointer-events: none;
}
.selection-scrim-shape {
fill: rgba(3, 8, 18, 0.76);
}
.selection-scrim[hidden] {
display: none;
}
.window-highlight,
.target-outline {
position: absolute;
z-index: 20;
border: 2px solid #2dd4bf;
border-radius: 4px;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.82),
0 0 24px rgba(45, 212, 191, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.target-outline {
z-index: 15;
border-style: dashed;
border-color: rgba(45, 212, 191, 0.78);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.72);
}
.region-preset-toolbar {
position: absolute;
z-index: 70;
display: grid;
width: 176px;
gap: 8px;
padding: 12px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.96);
border: 1px solid rgba(148, 163, 184, 0.5);
border-radius: 10px;
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.5);
}
.region-preset-toolbar[hidden] {
display: none;
}
.preset-toolbar-title {
padding: 2px 4px 6px;
color: #f8fafc;
font-size: 13px;
font-weight: 750;
}
.preset-button {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 2px 8px;
min-height: 54px;
padding: 9px 10px;
color: #cbd5e1;
text-align: left;
background: rgba(30, 41, 59, 0.92);
border: 1px solid #475569;
border-radius: 7px;
cursor: pointer;
}
.preset-button:hover {
color: #f8fafc;
background: #273449;
border-color: #64748b;
}
.preset-indicator {
grid-row: 1 / span 2;
align-self: center;
width: 10px;
height: 10px;
background: #64748b;
border-radius: 999px;
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.2);
}
.preset-button > span:nth-child(2) {
overflow: hidden;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.preset-state {
color: #94a3b8;
font-size: 11px;
}
.preset-button.is-active {
color: #ecfdf5;
background: rgba(6, 78, 59, 0.94);
border-color: #34d399;
}
.preset-button.is-active .preset-indicator {
background: #34d399;
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.22);
}
.preset-button.is-active .preset-state {
color: #a7f3d0;
}
.preset-button.is-armed {
border-color: #fbbf24;
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.28);
}
.preset-button.is-armed .preset-indicator {
background: #fbbf24;
}
.window-label {
position: absolute;
left: -2px;
top: -42px;
max-width: min(640px, 80vw);
min-height: 34px;
padding: 8px 12px;
overflow: hidden;
color: #ecfeff;
font-size: 13px;
font-weight: 650;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(8, 47, 73, 0.94);
border: 1px solid rgba(94, 234, 212, 0.72);
border-radius: 6px;
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.35);
}
.annotation-layer {
position: absolute;
inset: 0;
z-index: 30;
pointer-events: none;
}
.annotation-box {
position: absolute;
min-width: 1px;
min-height: 1px;
border: 2px solid #2dd4bf;
border-radius: 3px;
background: rgba(13, 148, 136, 0.1);
box-shadow:
0 0 0 1px rgba(2, 6, 23, 0.78),
0 4px 18px rgba(13, 148, 136, 0.22);
pointer-events: auto;
cursor: move;
}
.annotation-box:hover,
.annotation-box.is-selected {
border-color: #fb923c;
background: rgba(234, 88, 12, 0.13);
box-shadow:
0 0 0 1px rgba(67, 20, 7, 0.84),
0 4px 22px rgba(234, 88, 12, 0.32);
}
.annotation-box.is-draft {
border-style: dashed;
border-color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
pointer-events: none;
}
.annotation-label {
position: absolute;
left: -2px;
top: -29px;
max-width: 320px;
padding: 4px 8px;
overflow: hidden;
color: #ecfeff;
font-size: 12px;
font-weight: 650;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(15, 118, 110, 0.96);
border: 1px solid rgba(153, 246, 228, 0.7);
border-radius: 4px 4px 4px 0;
pointer-events: none;
}
.annotation-box.is-selected .annotation-label {
color: #fff7ed;
background: rgba(194, 65, 12, 0.96);
border-color: rgba(254, 215, 170, 0.78);
}
.resize-handle {
position: absolute;
right: -9px;
bottom: -9px;
width: 18px;
height: 18px;
padding: 0;
background: #fff7ed;
border: 2px solid #ea580c;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(67, 20, 7, 0.35);
cursor: nwse-resize;
}
.preset-button:focus-visible,
.resize-handle:focus-visible,
.editor-close:focus-visible,
.editor-save:focus-visible,
.annotation-editor input:focus-visible,
.annotation-editor .editor-select:focus-visible,
.annotation-editor textarea:focus-visible {
outline: 3px solid rgba(45, 212, 191, 0.92);
outline-offset: 2px;
}
.overlay-hint {
position: fixed;
z-index: 80;
left: 50%;
bottom: 28px;
width: max-content;
max-width: min(760px, calc(100vw - 32px));
min-height: 42px;
padding: 10px 16px;
color: #f8fafc;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-align: center;
background: rgba(15, 23, 42, 0.94);
border: 1px solid rgba(148, 163, 184, 0.48);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(2, 6, 23, 0.38);
transform: translateX(-50%);
pointer-events: none;
}
.overlay-hint.is-error {
color: #fff7ed;
background: rgba(127, 29, 29, 0.96);
border-color: rgba(254, 202, 202, 0.74);
}
.overlay-hint.is-success {
color: #ecfdf5;
background: rgba(6, 78, 59, 0.96);
border-color: rgba(110, 231, 183, 0.74);
}
.annotation-editor {
position: fixed;
z-index: 100;
width: 320px;
padding: 16px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(94, 234, 212, 0.44);
border-radius: 10px;
box-shadow: 0 20px 48px rgba(2, 6, 23, 0.52);
cursor: default;
user-select: text;
}
.annotation-editor[hidden] {
display: none;
}
.editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 36px;
margin-bottom: 12px;
color: #f8fafc;
font-size: 16px;
}
.editor-close {
display: grid;
width: 36px;
height: 36px;
padding: 0;
color: #cbd5e1;
font-size: 24px;
line-height: 1;
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
place-items: center;
cursor: pointer;
}
.editor-close:hover {
color: #fff;
background: rgba(148, 163, 184, 0.16);
border-color: rgba(148, 163, 184, 0.3);
}
.annotation-editor label {
display: grid;
gap: 6px;
margin-top: 12px;
color: #cbd5e1;
font-size: 12px;
font-weight: 650;
}
.annotation-editor input,
.annotation-editor textarea,
.annotation-editor .editor-select {
width: 100%;
min-height: 40px;
padding: 7px 10px;
color: #f8fafc;
background: #0f172a;
border: 1px solid #475569;
border-radius: 4px;
user-select: text;
}
.editor-select {
display: flex;
align-items: center;
justify-content: space-between;
cursor: default;
}
.editor-select-chevron {
color: #94a3b8;
font-size: 16px;
}
.annotation-editor textarea {
min-height: 76px;
resize: vertical;
}
.annotation-editor input:hover,
.annotation-editor textarea:hover,
.annotation-editor .editor-select:hover {
border-color: #64748b;
}
.editor-save {
width: 100%;
min-height: 44px;
margin-top: 16px;
color: #fff;
font-weight: 700;
background: #0d9488;
border: 1px solid #2dd4bf;
border-radius: 7px;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease;
}
.editor-save:hover {
background: #0f766e;
border-color: #5eead4;
}
@media (prefers-reduced-motion: reduce) {
.editor-save {
transition: none;
}
}

708
src/overlay.js Normal file
View File

@ -0,0 +1,708 @@
import { invoke } from "@tauri-apps/api/core";
import "./overlay.css";
const REGION_PRESETS = [
{ type: "contact_list", label: "联系人区域" },
{ type: "chat_content", label: "聊天内容区域" },
{ type: "input_box", label: "输入框区域" },
{ type: "unread_message", label: "未读消息区域" },
];
const REGION_PRESET_BY_TYPE = new Map(REGION_PRESETS.map((preset) => [preset.type, preset]));
const LEGACY_REGION_LABELS = {
send_button: "发送按钮区域(旧数据)",
custom: "自定义区域(旧数据)",
};
function regionTypeLabel(regionType) {
return REGION_PRESET_BY_TYPE.get(regionType)?.label || LEGACY_REGION_LABELS[regionType] || regionType;
}
const root = document.querySelector("#overlay-root");
if (!root) throw new Error("overlay root not found");
root.innerHTML = `
<svg class="selection-scrim" aria-hidden="true" preserveAspectRatio="none">
<path class="selection-scrim-shape" fill-rule="evenodd"></path>
</svg>
<div class="window-highlight" aria-hidden="true">
<div class="window-label"></div>
</div>
<div class="target-outline" aria-hidden="true"></div>
<aside class="region-preset-toolbar" aria-label="标注区域类型" hidden>
<div class="preset-toolbar-title">选择标注区域</div>
${REGION_PRESETS.map(
(preset) => `
<button type="button" class="preset-button" data-region-type="${preset.type}" aria-pressed="false">
<span class="preset-indicator" aria-hidden="true"></span>
<span>${preset.label}</span>
<span class="preset-state">未标注</span>
</button>
`,
).join("")}
</aside>
<div class="annotation-layer" aria-label="标注区域"></div>
<div class="overlay-hint" role="status" aria-live="polite"></div>
<form class="annotation-editor" aria-label="编辑标注" hidden>
<div class="editor-heading">
<strong>编辑标注</strong>
<button type="button" class="editor-close" data-action="close" aria-label="关闭编辑浮层">×</button>
</div>
<label>
<span>区域名称</span>
<input name="name" type="text" maxlength="80" autocomplete="off" />
</label>
<label>
<span id="editor-region-type-label">区域类型</span>
<input name="regionType" type="hidden" />
<div class="editor-select" role="combobox" aria-expanded="false" aria-readonly="true" aria-labelledby="editor-region-type-label" tabindex="0">
<span data-region-type-label></span>
<span class="editor-select-chevron" aria-hidden="true"></span>
</div>
</label>
<label>
<span>用途描述</span>
<textarea name="description" rows="3" maxlength="300"></textarea>
</label>
<button type="submit" class="editor-save">保存</button>
</form>
`;
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);

View File

@ -3,6 +3,7 @@ import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Check, MousePointer2, Trash2, X } from "lucide-react"; import { Check, MousePointer2, Trash2, X } from "lucide-react";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { closeWindow } from "../utils/navigation";
const regionTypes = [ const regionTypes = [
{ value: "contact_list", label: "联系人区域" }, { value: "contact_list", label: "联系人区域" },
@ -91,6 +92,143 @@ function formatCaptureTiming(capture, frontendMs) {
].join(" · "); ].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() { export default function AnnotationPage() {
const [capture, setCapture] = useState(emptyCapture); const [capture, setCapture] = useState(emptyCapture);
const [regions, setRegions] = useState([]); const [regions, setRegions] = useState([]);
@ -99,10 +237,14 @@ export default function AnnotationPage() {
const [drawEnd, setDrawEnd] = useState(null); const [drawEnd, setDrawEnd] = useState(null);
const [status, setStatus] = useState("正在准备截图..."); const [status, setStatus] = useState("正在准备截图...");
const [saving, setSaving] = useState(false); 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 selectedRegion = regions.find((region) => region.id === selectedId) || null;
const screenshotSrc = capture.screenshotPath ? convertFileSrc(capture.screenshotPath) : "";
const tempBox = drawStart && drawEnd ? normalizeBox(drawStart, drawEnd) : null; const tempBox = drawStart && drawEnd ? normalizeBox(drawStart, drawEnd) : null;
const typeLabelMap = useMemo( 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -120,7 +314,7 @@ export default function AnnotationPage() {
if (!cancelled) { if (!cancelled) {
setCapture(nextCapture); setCapture(nextCapture);
setStatus("拖拽截图区域创建矩形框"); setStatus("拖拽截图区域创建矩形框");
if (window.__TAURI_INTERNALS__) { if (window.__TAURI_INTERNALS__ && getCurrentWindowLabel() === "main") {
await getCurrentWindow().setFullscreen(true); await getCurrentWindow().setFullscreen(true);
} }
} }
@ -186,7 +380,9 @@ export default function AnnotationPage() {
sessionStorage.setItem("pendingAnnotationCapture", JSON.stringify(nextCapture)); sessionStorage.setItem("pendingAnnotationCapture", JSON.stringify(nextCapture));
setCapture(nextCapture); setCapture(nextCapture);
setStatus(formatCaptureTiming(nextCapture, performance.now() - startedAt)); 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(() => {}); loadSaved().catch(() => {});
}, []); }, []);
function getImagePoint(event) { useEffect(() => {
const rect = imageRef.current?.getBoundingClientRect(); const canvas = canvasRef.current;
if (!rect || !capture.screenshotWidth || !capture.screenshotHeight) return null; const shell = canvasShellRef.current;
if (!canvas || !shell) return undefined;
const displayX = clamp(event.clientX - rect.left, 0, rect.width); const updateCanvasSize = () => {
const displayY = clamp(event.clientY - rect.top, 0, rect.height); const rect = shell.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
return { const width = Math.max(1, Math.round(rect.width * ratio));
x: (displayX * capture.screenshotWidth) / rect.width, const height = Math.max(1, Math.round(rect.height * ratio));
y: (displayY * capture.screenshotHeight) / rect.height, 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) { function handlePointerDown(event) {
if (event.button !== 0) return; if (event.button !== 0) return;
const point = getImagePoint(event); const point = getCanvasImagePoint(event, canvasRef.current, imageViewport, capture);
if (!point) return; if (!point) return;
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
@ -239,18 +475,23 @@ export default function AnnotationPage() {
function handlePointerMove(event) { function handlePointerMove(event) {
if (!drawStart) return; if (!drawStart) return;
const point = getImagePoint(event); const point = getCanvasImagePoint(event, canvasRef.current, imageViewport, capture, true);
if (point) setDrawEnd(point); if (point) setDrawEnd(point);
} }
function handlePointerUp(event) { function handlePointerUp(event) {
if (!drawStart || !drawEnd) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) { if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(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 width = box[2] - box[0];
const height = box[3] - box[1]; const height = box[3] - box[1];
setDrawStart(null); setDrawStart(null);
@ -292,7 +533,7 @@ export default function AnnotationPage() {
if (window.__TAURI_INTERNALS__) { if (window.__TAURI_INTERNALS__) {
const currentWindow = getCurrentWindow(); const currentWindow = getCurrentWindow();
if (currentWindow.label !== "main") { if (currentWindow.label !== "main") {
await currentWindow.close(); await closeWindow();
return; return;
} }
@ -344,6 +585,7 @@ export default function AnnotationPage() {
</div> </div>
<h1 className="mt-2 text-[20px] font-extrabold">微信 RPA 坐标配置</h1> <h1 className="mt-2 text-[20px] font-extrabold">微信 RPA 坐标配置</h1>
<p className="mt-2 text-[12px] leading-5 text-muted">{status}</p> <p className="mt-2 text-[12px] leading-5 text-muted">{status}</p>
{imageLoadError ? <p className="mt-2 break-all text-[11px] leading-5 text-red-400">{imageLoadError}</p> : null}
</div> </div>
<div className="mt-4 grid grid-cols-2 gap-2 rounded-[12px] border border-line bg-surface2 p-3 text-[11px] text-muted"> <div className="mt-4 grid grid-cols-2 gap-2 rounded-[12px] border border-line bg-surface2 p-3 text-[11px] text-muted">
@ -445,79 +687,16 @@ export default function AnnotationPage() {
</div> </div>
</aside> </aside>
<main className="flex min-w-0 flex-1 items-center justify-center overflow-auto bg-surface2 p-6"> <main className="flex min-w-0 flex-1 overflow-hidden bg-surface2 p-6">
<div className="relative max-h-full max-w-full select-none shadow-[0_20px_80px_rgba(0,0,0,.55)]"> <div ref={canvasShellRef} className="min-h-0 min-w-0 flex-1 overflow-hidden rounded-[20px] border border-line bg-black shadow-[0_20px_80px_rgba(0,0,0,.55)]">
{screenshotSrc ? ( <canvas
<> ref={canvasRef}
<img className="block h-full w-full touch-none select-none"
ref={imageRef} onPointerDown={handlePointerDown}
src={screenshotSrc} onPointerMove={handlePointerMove}
alt="当前屏幕截图" onPointerUp={handlePointerUp}
className="block max-h-[calc(100dvh-48px)] max-w-full object-contain" onPointerCancel={handlePointerCancel}
draggable={false} />
onLoad={() => setStatus("拖拽截图区域创建矩形框")}
onError={() => setStatus(`截图图片加载失败:${capture.screenshotPath}`)}
/>
<svg
className="absolute inset-0 h-full w-full touch-none"
viewBox={`0 0 ${capture.screenshotWidth} ${capture.screenshotHeight}`}
preserveAspectRatio="none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
<rect width={capture.screenshotWidth} height={capture.screenshotHeight} fill="transparent" />
{regions.map((region) => {
const [x1, y1, x2, y2] = region.bbox_image;
const selected = region.id === selectedId;
return (
<g key={region.id} onPointerDown={(event) => { event.stopPropagation(); setSelectedId(region.id); }}>
<rect
x={x1}
y={y1}
width={x2 - x1}
height={y2 - y1}
fill={selected ? "rgba(7,193,96,0.28)" : "rgba(45,140,255,0.18)"}
stroke={selected ? "#07c160" : "#2d8cff"}
strokeWidth={selected ? 5 : 3}
vectorEffect="non-scaling-stroke"
/>
<text
x={x1 + 8}
y={Math.max(y1 + 24, 24)}
fill="#fff"
stroke="rgba(0,0,0,.7)"
strokeWidth="4"
paintOrder="stroke"
fontSize="18"
fontWeight="800"
>
{region.name}
</text>
</g>
);
})}
{tempBox ? (
<rect
x={tempBox[0]}
y={tempBox[1]}
width={tempBox[2] - tempBox[0]}
height={tempBox[3] - tempBox[1]}
fill="rgba(7,193,96,0.2)"
stroke="#07c160"
strokeDasharray="10 8"
strokeWidth="4"
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
</>
) : (
<div className="grid h-[360px] w-[640px] place-items-center rounded-[20px] border border-line bg-surface text-muted">
{status}
</div>
)}
</div> </div>
</main> </main>
</div> </div>

View File

@ -1,207 +1,234 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { Crosshair, Power, PowerOff, Settings } from "lucide-react"; import { Crosshair, Power } from "lucide-react";
import StatusPill from "../components/StatusPill"; import NodeSphere from "../components/NodeSphere";
import Terminal from "../components/Terminal"; import Terminal from "../components/Terminal";
import { openWindow } from "../utils/navigation"; 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() { export default function ClonePage() {
const [engineEnabled, setEngineEnabled] = useState(false); const [engineStatus, setEngineStatus] = useState("idle");
const [engineBusy, setEngineBusy] = useState(false); const [engineBusy, setEngineBusy] = useState(false);
const [agentLogs, setAgentLogs] = useState([]); const [annotationBusy, setAnnotationBusy] = useState(false);
const [annotating, setAnnotating] = useState(false); const [annotationComplete, setAnnotationComplete] = useState(false);
const [sourcePickerOpen, setSourcePickerOpen] = useState(false); const [agentLogs, setAgentLogs] = useState([{ level: "info", message: "[12:30:01] 等待启动" }]);
const [captureSources, setCaptureSources] = useState([]); const [checkOpen, setCheckOpen] = useState(false);
const [sourceStatus, setSourceStatus] = useState(""); const [checks, setChecks] = useState(initialChecks);
const [checkPhase, setCheckPhase] = useState("idle");
const [autoSendPaused, setAutoSendPaused] = useState(false);
const engineRunning = engineStatus === "running";
const statusContent = engineStatusContent[engineStatus];
useEffect(() => { useEffect(() => {
if (!window.__TAURI_INTERNALS__) return undefined; if (!window.__TAURI_INTERNALS__) return undefined;
let unlisten; let unlisten;
let cancelled = false; let cancelled = false;
listen("engine-state-changed", (event) => { 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); setEngineBusy(false);
setAgentLogs((currentLogs) => [ }).then((cleanup) => { if (cancelled) cleanup(); else unlisten = cleanup; });
...currentLogs.slice(-300), return () => { cancelled = true; if (unlisten) unlisten(); };
{ level: "warning", message: "工作台已关闭,引擎状态已同步" },
]);
}).then((cleanup) => {
if (cancelled) {
cleanup();
return;
}
unlisten = cleanup;
});
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []); }, []);
async function startAnnotation() { useEffect(() => {
if (!window.__TAURI_INTERNALS__) { if (!window.__TAURI_INTERNALS__) return undefined;
window.location.hash = "/annotate"; let unlisten;
return; 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));
} }
void observeAnnotationCompletion();
setSourcePickerOpen(true); return () => { cancelled = true; if (unlisten) unlisten(); };
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);
}
async function toggleAgent() { async function toggleAgent() {
if (engineBusy) return; if (engineBusy) return false;
if (!window.__TAURI_INTERNALS__) { if (!window.__TAURI_INTERNALS__) {
setEngineEnabled((enabled) => !enabled); setEngineStatus(engineRunning ? "idle" : "running");
return; setAutoSendPaused(false);
setAgentLogs((current) => [...current, { level: "info", message: engineRunning ? "[12:38:30] 引擎已停止" : "[12:38:21] 引擎启动成功" }]);
return true;
} }
setEngineBusy(true); setEngineBusy(true);
try { try {
if (engineEnabled) { if (engineRunning) {
await invoke("stop_agent"); await invoke("stop_agent");
await invoke("stop_vision_stream").catch((error) => { await invoke("stop_vision_stream");
setAgentLogs((currentLogs) => [ setEngineStatus("idle");
...currentLogs, setAutoSendPaused(false);
{ level: "warning", message: `停止视觉流失败:${String(error)}` }, setAgentLogs((current) => [...current.slice(-300), { level: "warning", message: "引擎已停用,暂未读取 Rust 日志" }]);
]);
});
setEngineEnabled(false);
setAgentLogs((currentLogs) => [
...currentLogs.slice(-300),
{ level: "warning", message: "引擎已停用,暂未读取 Rust 日志" },
]);
} else { } else {
await invoke("start_vision_stream"); await invoke("start_vision_stream");
await invoke("start_agent").catch((error) => { try { await invoke("start_agent"); } catch (error) { await invoke("stop_vision_stream").catch(() => {}); throw new Error(`agent 启动失败:${String(error)}`); }
setAgentLogs((currentLogs) => [ setEngineStatus("running");
...currentLogs.slice(-300), setAutoSendPaused(false);
{ level: "warning", message: `agent 启动失败,预览已继续启动:${String(error)}` }, setAgentLogs((current) => [...current.slice(-300), { level: "info", message: "引擎已启动,暂未读取 Rust 日志" }]);
]);
});
setEngineEnabled(true);
setAgentLogs((currentLogs) => [
...currentLogs.slice(-300),
{ level: "info", message: "引擎已启动,暂未读取 Rust 日志" },
]);
await openWindow("/window/engine-workbench");
} }
return true;
} catch (error) { } catch (error) {
setAgentLogs((currentLogs) => [ setEngineStatus("error");
...currentLogs, setAgentLogs((current) => [...current, { level: "error", message: String(error) }]);
{ level: "error", message: String(error) }, return false;
]);
} finally { } finally {
setEngineBusy(false); 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 ( return (
<section className="flex min-h-full flex-1 flex-col gap-3"> <section className="flex min-h-full flex-1 flex-col gap-3">
<div className="flex justify-end"> <div className="engine-visual flex flex-col items-center gap-5 px-2 py-2">
<StatusPill tone="success" label="大模型已连接" /> <NodeSphere status={engineStatus} />
</div> <div className="text-center" aria-live="polite"><h2 className="text-[22px] font-extrabold">{statusContent.title}</h2><p className="mt-2 text-[13px] text-muted">{statusContent.description}</p></div>
<div className="flex flex-col items-center gap-5 px-6 py-2"> {engineRunning ? (
<div className="engine-sphere engine-success" aria-hidden="true" /> <div className="grid w-full grid-cols-2 gap-2">
<div className="text-center"> <button className="btn-secondary h-10 w-full" onClick={toggleAutoSend} disabled={engineBusy}>
<h2 className="text-[22px] font-extrabold">微信引擎运行中</h2> {autoSendPaused ? "恢复自动发送" : "暂停自动发送"}
<p className="mt-2 text-[13px] text-muted">微信适配层已就绪等待本地消息事件</p> </button>
</div> <button
<div className="flex w-full items-center justify-center gap-4"> className="btn-danger h-10 w-full"
<button onClick={() => void toggleAgent()}
className={`${engineEnabled ? "btn-danger" : "btn-primary"} w-full gap-2`} disabled={engineBusy}
onClick={toggleAgent} >
disabled={engineBusy} 停止引擎
> </button>
{engineEnabled ? <PowerOff size={16} strokeWidth={2.6} /> : <Power size={16} strokeWidth={2.6} />}
{engineBusy ? "处理中" : engineEnabled ? "停用引擎" : "启动引擎"}
</button>
<button className="icon-action shrink-0" onClick={() => openWindow("/window/settings")} aria-label="设置"><Settings size={18} strokeWidth={2.5} /></button>
</div>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-[16px] font-extrabold">屏幕区域标注</h3>
<p className="mt-1 text-[12px] text-muted">截取当前屏幕配置联系人聊天内容输入框和发送按钮坐标</p>
</div> </div>
<button className="btn-primary shrink-0 gap-2" onClick={startAnnotation} disabled={annotating}> ) : annotationComplete ? (
<Crosshair size={16} strokeWidth={2.6} /> <button className="btn-primary w-full gap-2" onClick={requestToggle} disabled={engineBusy}>
{annotating ? "截屏中" : "开始标注"} <Power size={16} />
{engineBusy ? "处理中" : engineStatus === "error" ? "重新启动" : "启动引擎"}
</button> </button>
</div> ) : (
<button className="btn-primary w-full gap-2" onClick={startAnnotation} disabled={annotationBusy}>
<Crosshair size={16} />
{annotationBusy ? "正在打开" : "标注微信"}
</button>
)}
</div> </div>
{sourcePickerOpen ? ( <Terminal title={engineRunning ? "最近日志" : "运行日志"} lines={agentLogs} onViewAll={() => openWindow("/window/engine-logs")} onClear={() => setAgentLogs([])} />
<div className="fixed inset-0 z-50 grid place-items-center bg-black/35 px-5"> {checkOpen ? <StartupCheckModal checks={checks} phase={checkPhase} /> : null}
<div className="panel max-h-[78dvh] w-full max-w-[440px] overflow-hidden p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-[17px] font-extrabold">选择标注来源 Demo</h3>
<p className="mt-1 text-[12px] text-muted">可以选择某个桌面或某个可见应用窗口</p>
</div>
<button className="btn-ghost" onClick={() => setSourcePickerOpen(false)}>关闭</button>
</div>
<div className="mt-3 rounded-[12px] bg-surface2 px-3 py-2 text-[12px] text-muted">{sourceStatus}</div>
<div className="mt-3 max-h-[54dvh] space-y-2 overflow-y-auto pr-1">
{captureSources.map((source) => (
<button
key={source.id}
className="list-card w-full !rounded-[12px] text-left transition hover:border-primary"
onClick={() => beginAnnotationWithSource(source)}
>
<div className="min-w-0">
<div className="truncate text-[13px] font-extrabold">{source.label}</div>
<div className="mt-1 text-[11px] text-muted">
{source.kind === "display" ? "桌面" : "窗口"} · {source.width}x{source.height} · x:{source.x} y:{source.y}
</div>
</div>
<span className="tag shrink-0">{source.kind === "display" ? "桌面" : "窗口"}</span>
</button>
))}
</div>
</div>
</div>
) : null}
<Terminal title="运行日志" lines={agentLogs} onClear={() => setAgentLogs([])} />
</section> </section>
); );
} }
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 (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-3 backdrop-blur-[2px]">
<div className="startup-check-terminal w-full max-w-[520px] overflow-hidden shadow-2xl" aria-live="polite">
<div
className="startup-check-progress"
role="progressbar"
aria-label="启动检查进度"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={progress}
>
<div className="startup-check-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="startup-check-body">
<p className="startup-check-intro">
正在检测引擎启动环境<span className="startup-log-cursor" aria-hidden="true">_</span>
</p>
<div className="space-y-2">
{visibleChecks.map((item) => (
<div key={item.id} className={`startup-check-line startup-log-enter startup-check-${item.status}`}>
<span className="shrink-0">{item.label}</span>
<span className="startup-check-leader" aria-hidden="true" />
<span className="shrink-0 font-bold">
{item.status === "checking" ? "检测中..." : item.status === "passed" ? "成功" : "失败"}
</span>
</div>
))}
</div>
{phase === "starting" ? <p className="startup-check-summary startup-log-enter">全部检查成功正在启动引擎...</p> : null}
{phase === "failed" || phase === "failed-start" ? <p className="startup-check-error startup-log-enter">检查失败启动流程已终止</p> : null}
</div>
</div>
</div>
);
}

View File

@ -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 { employeeItems } from "../data/mockData";
import { openWindow } from "../utils/navigation"; import { openWindow } from "../utils/navigation";
export default function DistillPage() { 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 ( return (
<section className="space-y-5"> <section className="space-y-4 pb-4">
<div className=" p-5 text-center"> <div className="text-center">
<h2 className="text-[24px] font-extrabold leading-tight">把销售冠军蒸馏成员工 skill</h2> <h2 className="text-[22px] font-extrabold leading-tight">
<p className="mt-3 text-[13px] text-muted">导入聊天记录话术和案例沉淀可授权调用的员工能力</p> 把优秀话术沉淀成员工能力
<div className="mt-5 w-full flex flex-wrap gap-8"> </h2>
<button className="btn-secondary flex-1">蒸馏教程</button> <p className="mt-2 text-[12px] leading-5 text-muted">
<button className="btn-primary flex-1" onClick={() => openWindow("/window/distill-start")}>开始蒸馏</button> 导入聊天记录话术和案例形成可评估可授权的员工
</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<button
className="btn-secondary gap-2"
onClick={() => setTutorial(true)}
>
<BookOpen size={15} />
查看教程
</button>
<button
className="btn-primary"
onClick={() => openWindow("/window/distill-start")}
>
开始新的蒸馏
</button>
</div> </div>
</div> </div>
<div className="panel rounded-md p-3"> {taskVisible ? (
<div className="flex items-center justify-between"> <div>
<div> <div className="mb-2 text-[12px] font-black text-muted">当前任务</div>
<h3 className="text-[15px] font-extrabold">销售冠军 Aileen 话术蒸馏</h3> <article
<p className="mt-1 text-[12px] text-muted"> className="panel cursor-pointer rounded-[14px] p-4"
<span className="text-primary">成功 248 </span> onClick={() => openWindow("/window/distill-start?step=3")}
<span className="mx-1 text-subtle">·</span> >
<span className="text-error">失败 7 </span> <div className="flex items-start justify-between gap-3">
</p> <div>
</div> <h3 className="text-[14px] font-extrabold">
<span className="text-[13px] font-black text-primary">72%</span> 销售冠军 Aileen 话术蒸馏
</h3>
<p className="mt-1 text-[11px] text-muted">
能力提取 · 剩余约 3 分钟
</p>
</div>
<span className="text-[18px] font-black text-primary">72%</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-surface2">
<div className="h-full w-[72%] rounded-full bg-primary" />
</div>
<div className="mt-3 flex items-center justify-between text-[11px]">
<span>
<span className="text-primary">成功 248</span>
<span className="mx-2 text-subtle">·</span>
<span className="text-error">失败 7</span>
</span>
<div>
<button
className="btn-ghost h-8"
onClick={(event) => {
event.stopPropagation();
openWindow("/window/distill-start?step=3");
}}
>
查看
</button>
<button
className="btn-ghost h-8 text-error"
onClick={(event) => {
event.stopPropagation();
setTaskVisible(false);
}}
>
取消
</button>
</div>
</div>
</article>
</div> </div>
<div className="mt-4 h-2 rounded-full bg-surface2"> ) : null}
<div className="h-full w-[72%] rounded-full bg-primary" /> <div>
<div className="flex gap-2">
<div className="relative min-w-0 flex-1">
<Search
className="absolute left-3 top-[13px] text-muted"
size={15}
/>
<input
className="input-like h-[40px] pl-9"
placeholder="搜索员工"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{employeeItems.map((item) => ( {filtered.map((item, index) => (
<article key={item.name} className="panel rounded-md p-3" onClick={() => openWindow("/window/employee-detail")}> <article
<div className="flex items-center justify-between gap-3"> key={item.name}
<h3 className="min-w-0 truncate text-[15px] font-extrabold">{item.name}</h3> className="panel cursor-pointer rounded-[14px] p-4 transition hover:border-primary"
<div className="flex shrink-0 items-center gap-1.5 text-[12px] font-semibold text-muted"> onClick={() =>
<span>{item.version}</span> openWindow(`/window/employee-detail?id=employee-${index + 1}`)
<span className="text-subtle">·</span> }
<span className={item.enabled ? "skill-state-enabled" : "skill-state-disabled"}>{item.enabled ? "已启用" : "已关闭"}</span> >
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-[14px] font-extrabold">{item.name}</h3>
<p className="mt-1 text-[11px] text-muted">{item.domain}</p>
</div>
<div className="text-right text-[11px]">
<div className="font-bold text-warning">{item.version}</div>
<div
className={`mt-1 ${item.enabled ? "text-primary" : "text-muted"}`}
>
{item.enabled ? "● 已启用" : "○ 已关闭"}
</div>
</div> </div>
</div> </div>
<p className="mt-2 text-[13px] leading-6 text-muted">{item.traits}</p> <p className="mt-3 text-[12px] leading-5 text-muted">
{item.traits}
</p>
<div className="mt-3 flex items-center justify-between text-[11px] text-muted">
<span>
评分 {96 - index * 4} · 本周调用 {28 - index * 6}
</span>
<button className="btn-ghost h-8">查看详情</button>
</div>
</article> </article>
))} ))}
</div> </div>
{tutorial ? (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/45 p-5">
<div className="panel w-full max-w-[420px] p-5">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-black">员工蒸馏教程</h3>
<button className="icon-btn" onClick={() => setTutorial(false)}>
<X size={18} />
</button>
</div>
<ol className="mt-4 space-y-3 text-[13px] leading-6 text-muted">
<li>1. 导入已获授权的聊天话术或案例</li>
<li>2. 检查并确认联系人手机号和订单号脱敏</li>
<li>3. 等待能力提取处理失败样本</li>
<li>4. 通过效果评估后配置授权并发布员工</li>
</ol>
<button
className="btn-primary mt-5 w-full"
onClick={() => {
setTutorial(false);
openWindow("/window/distill-start");
}}
>
开始蒸馏
</button>
</div>
</div>
) : null}
</section> </section>
); );
} }

View File

@ -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 DraggableFab from "../components/DraggableFab";
import Segmented from "../components/Segmented";
import StatusPill from "../components/StatusPill"; import StatusPill from "../components/StatusPill";
import { openWindow } from "../utils/navigation"; import { openWindow } from "../utils/navigation";
export default function KnowledgePage({ active, setActive }) { const items = [
const types = ["全部", "视频", "图片", "文件", "案例", "其他"]; { id: "kb-sales-champion", title: "销售冠军高意向客户跟进案例", type: "案例", tags: ["私域成交", "高意向"], updated: "06-08", refs: 128, files: 3, version: "v2.4", status: "已生效" },
const filtered = active === "全部" ? knowledgeItems : knowledgeItems.filter((item) => item.type === active); { 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 ( return (
<section className="space-y-3 pb-20"> <section className="space-y-3 pb-20">
<Segmented items={types} active={active} onChange={setActive} /> <div className="relative"><Search className="absolute left-3 top-3 text-muted" size={16} /><input className="input-like pl-9" placeholder="搜索标题、标签或完整正文" value={query} onChange={(event) => setQuery(event.target.value)} /></div>
<div className="space-y-3">
<div className="space-y-3 [text-rendering:auto]">
{filtered.map((item) => ( {filtered.map((item) => (
<article key={item.title} className="list-card" onClick={() => openWindow("/window/knowledge-detail")}> <article key={item.id} className="panel cursor-pointer rounded-[14px] p-4 transition hover:border-primary" onClick={() => openWindow(`/window/knowledge-detail?id=${item.id}`)}>
<div className="min-w-0"> <div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-[15px] font-extrabold">{item.title}</h3><div className="mt-2 flex flex-wrap gap-1.5"><span className="tag text-[12px] font-semibold leading-4 text-text">{item.type}</span>{item.tags.map((tag) => <span key={tag} className="tag text-[12px] font-semibold leading-4 text-text">{tag}</span>)}</div></div><StatusPill tone={item.status === "已生效" ? "success" : item.status === "待审核" ? "warning" : "muted"} label={item.status} /></div>
<div className="flex items-center gap-2"> <div className="mt-3 flex items-center justify-between gap-3 text-[12px] font-medium leading-5 text-muted"><span>{item.version} · 更新 {item.updated} · 引用 {item.refs}</span><span className="shrink-0">文件 {item.files} </span></div>
<h3 className="truncate text-[14px] font-extrabold">{item.title}</h3>
</div>
<p className="mt-2 text-[12px] text-muted">更新 {item.updated} · 引用 {item.refs} </p>
</div>
<div className="flex shrink-0 self-center items-center justify-center gap-4">
<span className="text-[12px] font-bold text-warning">{item.version}</span>
<StatusPill tone={item.status === "已生效" ? "success" : item.status === "未生效" ? "warning" : "muted"} label={item.status} />
</div>
</article> </article>
))} ))}
{!filtered.length ? <div className="panel grid min-h-[220px] place-items-center p-6 text-center"><div><h3 className="text-[16px] font-black">暂无匹配知识</h3><p className="mt-2 text-[12px] text-muted">调整搜索关键词后重试</p><button className="btn-secondary mt-4" onClick={() => setQuery("")}>清除筛选</button></div></div> : null}
</div> </div>
<DraggableFab onClick={() => openWindow("/window/knowledge-create")} ariaLabel="新增知识库" /> <DraggableFab onClick={() => openWindow("/window/knowledge-create")} ariaLabel="新增知识库" />
</section> </section>

View File

@ -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 DraggableFab from "../components/DraggableFab";
import Segmented from "../components/Segmented"; import Segmented from "../components/Segmented";
import { SimpleSelect } from "../components/ui/select";
import { tagToneClasses } from "../data/mockData";
import { openWindow } from "../utils/navigation"; 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 }) { 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 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 ( return (
<section className="space-y-4 pb-20"> <section className="space-y-3 pb-20">
<Segmented items={types} active={active} onChange={setActive} /> <div className="relative"><Search className="absolute left-3 top-3 text-muted" size={16} /><input className="input-like pl-9" placeholder="搜索智能体名称或能力" value={query} onChange={(event) => setQuery(event.target.value)} /></div>
<div className="space-y-3"> <div className="space-y-3">
{filtered.map((skill) => ( {filtered.map((skill) => (
<article key={skill.name} className="panel rounded-md p-3"> <article key={skill.id} className="panel cursor-pointer rounded-[14px] p-4 transition hover:border-primary" onClick={() => openWindow(`/window/skill-detail?id=${skill.id}`)}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-[15px] font-extrabold">{skill.name}</h3></div><span className={skill.enabled ? "skill-state-enabled text-[12px] font-bold" : "skill-state-disabled text-[12px] font-bold"}>{skill.enabled ? "● 已启用" : "○ 已关闭"}</span></div>
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="mt-3 flex flex-wrap gap-2">{skill.tags.map((tag, index) => <span key={tag} className={`tag ${tagToneClasses[index % tagToneClasses.length]}`}>{tag}</span>)}</div>
<div className="flex w-full min-w-0 items-center justify-between gap-4"> <div className="mt-4 flex items-center justify-between gap-3"><span className={`text-[11px] ${skill.tested ? "text-muted" : "text-warning"}`}>{skill.meta}</span><div className="flex items-center gap-2"><button className={`mini-switch ${skill.enabled ? "is-on" : ""}`} aria-label={`${skill.name}${skill.enabled ? "关闭" : "启用"}`} onClick={(event) => { event.stopPropagation(); toggle(skill.id); }}><span /></button></div></div>
<h3 className="truncate text-[15px] font-extrabold">{skill.name}</h3>
<div className="flex shrink-0 items-center gap-1.5 text-[12px] font-semibold">
<span className="skill-type">{skill.type}</span>
<span className="text-subtle">·</span>
<span className={skill.enabled ? "skill-state-enabled" : "skill-state-disabled"}>{skill.enabled ? "已启用" : "已关闭"}</span>
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{skill.tags.map((tag, index) => <span key={tag} className={`tag ${tagToneClasses[index % tagToneClasses.length]}`}>{tag}</span>)}
</div>
</article> </article>
))} ))}
{!filtered.length ? <div className="panel grid min-h-[220px] place-items-center text-center"><div><strong>暂无匹配智能体</strong><p className="mt-2 text-[12px] text-muted">调整筛选条件后重试</p></div></div> : null}
</div> </div>
<DraggableFab onClick={() => openWindow("/window/skill-create")} ariaLabel="新增 skill" /> <DraggableFab onClick={() => openWindow("/window/skill-create")} ariaLabel="新增智能体" />
</section> </section>
); );
} }

73
src/utils/documentText.js Normal file
View File

@ -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;
}

View File

@ -7,25 +7,57 @@ export function go(path) {
export async function openWindow(path) { export async function openWindow(path) {
if (!window.__TAURI_INTERNALS__) { if (!window.__TAURI_INTERNALS__) {
go(path); const routeName = path
return; .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 { try {
await invoke("open_popup_window", { route: path }); await invoke("open_popup_window", { route: path });
return true;
} catch (error) { } catch (error) {
console.error("Failed to open popup window", error); console.error("Failed to open popup window", error);
go(path); return false;
} }
} }
export async function closeWindow() { export async function closeWindow() {
if (!window.__TAURI_INTERNALS__) { if (!window.__TAURI_INTERNALS__) {
go("/"); if (window.opener) window.close();
else go("/");
return; 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() { export async function startWindowDrag() {

View File

@ -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 = [
<SourceStep key="source" />,
<ExtractStep key="extract" />,
<EvaluateStep key="evaluate" />,
<PublishStep key="publish" name={name} setName={setName} />,
];
return <div className="flex min-h-0 flex-1 flex-col"><WizardSteps items={steps} step={step} onChange={setStep} /><WindowBody className="mx-auto w-full max-w-5xl">{pages[step]}<PageActions status={notice}>{step > 0 ? <button className="btn-secondary" onClick={() => setStep((value) => value - 1)}>上一步</button> : null}<button className="btn-secondary" onClick={() => setNotice("草稿已保存(页面演示)")}>保存草稿</button>{step < 3 ? <button className="btn-primary" onClick={() => setStep((value) => value + 1)}>下一步</button> : <button className="btn-primary" onClick={() => setNotice(`${name} 已发布(页面演示)`)}>发布员工</button>}</PageActions></WindowBody></div>;
}
function SourceStep() {
return <div className="grid gap-5"><div><button className="btn-primary">上传文件</button></div><div className="panel divide-y divide-line">{[["champion-scripts.docx", "38 条话术"]].map(([name, meta]) => <div key={name} className="flex items-center gap-3 p-4"><FileText className="text-primary" size={18} /><div className="flex-1"><strong className="text-[13px]">{name}</strong><p className="mt-1 text-[11px] text-muted">{meta}</p></div><StatusPill label="格式有效" /></div>)}</div><FormRow label="最少有效会话"><input className="input-like" type="number" defaultValue="50" /></FormRow></div>;
}
function ExtractStep() {
return <div className="grid gap-5"><div className="panel p-5"><div className="flex items-end justify-between"><div><h3 className="text-[17px] font-black">正在提取能力</h3><p className="mt-2 text-[12px] text-muted">任务可在后台继续运行</p></div><strong className="text-[24px] text-primary">72%</strong></div><div className="mt-4 h-2 overflow-hidden rounded-full bg-surface2"><div className="h-full w-[72%] rounded-full bg-primary" /></div></div><div className="panel divide-y divide-line">{[["数据清洗", "1,286 / 1,286", true], ["场景聚类", "18 个场景", true], ["策略提取", "13 / 18", false], ["边界归纳", "等待", false], ["反例生成", "等待", false]].map(([name, value, done]) => <div key={name} className="flex items-center justify-between p-4 text-[13px]"><span className="flex items-center gap-2">{done ? <Check className="text-primary" size={16} /> : <span className="h-4 w-4 rounded-full border-2 border-lineStrong" />}{name}</span><span className="text-muted">{value}</span></div>)}</div><div><h3 className="mb-3 text-[14px] font-black">已识别能力</h3><div className="flex flex-wrap gap-2">{["高意向识别", "异议拆解", "温和推进", "优惠边界", "长期跟进"].map((item, index) => <span key={item} className={`tag ${["tag-tone-green", "tag-tone-orange", "tag-tone-purple"][index % 3]}`}>{item}</span>)}</div></div></div>;
}
function EvaluateStep() {
return <div className="grid gap-5"><div className="flex items-center justify-between rounded-[18px] border border-[#bfe8d1] bg-[var(--primary-soft)] p-5"><div><div className="text-[34px] font-black text-primary">92</div><div className="text-[12px] text-muted">综合评分 / 100</div></div><StatusPill label="达到发布标准" /></div><div className="grid grid-cols-2 gap-3 md:grid-cols-4"><Metric label="场景命中" value="94%" /><Metric label="拒答准确" value="91%" /><Metric label="越权率" value="0.3%" /><Metric label="一致性" value="89%" /></div><div className="panel divide-y divide-line">{[["高意向询价", "建议合理", true], ["超边界优惠", "已拒绝越权", true], ["售后退款混合场景", "回答包含不确定信息", false]].map(([name, result, passed]) => <div key={name} className="flex items-center justify-between gap-4 p-4 text-[13px]"><span>{passed ? "✓" : "!"} {name}</span><span className={passed ? "text-primary" : "text-warning"}>{result}</span></div>)}</div><div className="flex gap-2"><button className="btn-secondary">重新评估</button><button className="btn-secondary">导出评估报告</button></div></div>;
}
function PublishStep({ name, setName }) {
return <div className="grid gap-5"><div className="grid gap-5 md:grid-cols-2"><FormRow label="员工名称" required><input className="input-like" value={name} onChange={(e) => setName(e.target.value)} /></FormRow><FormRow label="业务领域"><SimpleSelect ariaLabel="选择业务领域" defaultValue="企微私域成交" options={["企微私域成交", "B2B 咨询", "售后服务"]} /></FormRow></div><section><h3 className="mb-3 text-[14px] font-black">授权能力</h3><div className="panel grid gap-4 p-5 sm:grid-cols-2">{["客户意图识别", "报价策略生成", "售后安抚助手"].map((item, index) => <label key={item} className="flex items-center gap-2 text-[13px]"><input type="checkbox" defaultChecked={index < 2} />{item}</label>)}</div></section><section><h3 className="mb-3 text-[14px] font-black">授权知识</h3><div className="panel grid gap-4 p-5 sm:grid-cols-2">{["产品价格表", "报价与折扣边界", "售后退款政策"].map((item) => <label key={item} className="flex items-center gap-2 text-[13px]"><input type="checkbox" defaultChecked />{item}</label>)}</div></section></div>;
}
export function EmployeeDetailWindow() {
const [active, setActive] = useState("概览");
const [enabled, setEnabled] = useState(true);
return <WindowBody className="mx-auto w-full max-w-6xl"><div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex items-center gap-3"><h2 className="text-[24px] font-black">销售冠军 Aileen</h2><span className="text-warning">v3.2</span><StatusPill label={enabled ? "已启用" : "已关闭"} tone={enabled ? "success" : "muted"} /></div><p className="mt-2 text-[12px] text-muted">企微私域成交 · 更新于 2026-06-18</p></div><button className={enabled ? "btn-danger" : "btn-primary"} onClick={() => setEnabled((value) => !value)}>{enabled ? "停用" : "启用"}</button></div><div className="mt-5"><WindowTabs items={employeeTabs} active={active} onChange={setActive} /></div><div className="mt-5">{active === "概览" ? <EmployeeOverview /> : <EmployeeTab active={active} />}</div></WindowBody>;
}
function EmployeeOverview() {
return <div className="grid gap-5"><div className="panel p-5"><div className="flex items-center justify-between"><h3 className="text-[16px] font-black">员工画像</h3><button className="btn-ghost">编辑画像</button></div><p className="mt-3 text-[13px] font-bold">高意向逼单 / 异议拆解 / 温和推进</p><p className="mt-3 text-[12px] leading-6 text-muted">适用已有明确需求处于报价或决策阶段的私域客户<br />禁止承诺未授权折扣绕过人工确认直接发送敏感信息</p></div><div className="grid grid-cols-2 gap-3 md:grid-cols-4"><Metric label="综合评分" value="96" /><Metric label="本周调用" value="28" /><Metric label="采纳率" value="82%" /><Metric label="异常" value="1 次" tone="text-warning" /></div><div className="panel divide-y divide-line"><div className="flex items-center justify-between p-4 text-[13px]"><span>绑定能力客户意图识别报价策略生成</span><button className="btn-ghost">管理授权</button></div><div className="flex items-center justify-between p-4 text-[13px]"><span>绑定知识产品价格表报价边界高意向案例</span><button className="btn-ghost">管理知识</button></div></div><div className="flex flex-wrap gap-2"><button className="btn-primary">重新蒸馏</button><button className="btn-secondary">复制为新员工</button><button className="btn-secondary">更多操作</button></div></div>;
}
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 <div className="panel divide-y divide-line">{rows.map((row) => <div key={row} className="p-4 text-[13px] font-semibold">{row}</div>)}</div>;
}

View File

@ -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 (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col p-5">
<div className="flex flex-wrap gap-2">
<div className="w-[140px]"><SimpleSelect ariaLabel="筛选日志级别" value={level} onValueChange={setLevel} options={["全部级别", "info", "warning", "error"]} /></div>
<div className="w-[150px]"><SimpleSelect ariaLabel="筛选日志模块" value={module} onValueChange={setModule} options={["全部模块", ...modules]} /></div>
<div className="relative min-w-[240px] flex-1"><Search className="absolute left-3 top-3 text-muted" size={16} /><input className="input-like pl-9" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="搜索消息或 requestId" /></div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-[12px] text-muted"><input type="checkbox" defaultChecked />自动滚动</label>
<div className="flex gap-2"><button className="btn-secondary h-9 gap-2" onClick={() => setPaused((value) => !value)}>{paused ? <Play size={14} /> : <Pause size={14} />}{paused ? "继续接收" : "暂停接收"}</button><button className="btn-secondary h-9 gap-2"><Download size={14} />导出</button><button className="btn-secondary h-9 gap-2 text-error" onClick={() => setVisible([])}><Trash2 size={14} />清空视图</button></div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-auto rounded-[12px] border border-line bg-[#0b100d] font-mono text-[12px]">
{filtered.length ? filtered.map((item) => <button key={item.id} className="grid w-full grid-cols-[74px_70px_84px_minmax(0,1fr)] gap-3 border-b border-white/5 px-4 py-2 text-left hover:bg-white/5" onClick={() => setSelected(item)}><span className="text-[#82c7ff]">{item.time}</span><span className={`uppercase terminal-line-${item.level}`}>{item.level}</span><span className="text-[#c9a7ff]">{item.module}</span><span className="truncate text-[#d7e8df]">{item.message}</span></button>) : <div className="grid h-full min-h-[300px] place-items-center text-[#7f8c85]">当前筛选下暂无日志</div>}
</div>
<div className="mt-3 flex justify-between text-[11px] text-muted"><span>已显示 {filtered.length} / {visible.length} · 缓存上限 10,000</span><span>{paused ? "已暂停接收 · 新日志 3 条" : "最后更新 12:30:15"}</span></div>
</div>
{selected ? <aside className="absolute inset-y-0 right-0 z-20 w-[390px] max-w-[90%] border-l border-line bg-page p-5 shadow-2xl"><div className="flex items-center justify-between"><h2 className="text-[19px] font-black">日志详情</h2><button className="icon-btn" onClick={() => setSelected(null)}><X size={18} /></button></div><dl className="mt-6 grid grid-cols-[84px_1fr] gap-y-4 text-[12px]"><dt className="text-muted">时间</dt><dd>2026-07-20 {selected.time}.238</dd><dt className="text-muted">级别</dt><dd className={`uppercase terminal-line-${selected.level}`}>{selected.level}</dd><dt className="text-muted">模块</dt><dd>{selected.module}</dd><dt className="text-muted">requestId</dt><dd>{selected.requestId}</dd><dt className="text-muted">消息</dt><dd className="leading-6">{selected.message}</dd><dt className="text-muted">上下文</dt><dd>snapshot-9821 snapshot-9822</dd></dl><div className="mt-8 flex flex-wrap gap-2"><button className="btn-primary gap-2"><Copy size={14} />复制完整日志</button><button className="btn-secondary" onClick={() => { setQuery(selected.requestId); setSelected(null); }}>仅筛选此 requestId</button></div></aside> : null}
</div>
);
}

View File

@ -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 (
<div className="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_minmax(320px,400px)] gap-4 p-4 max-lg:grid-cols-1">
<section className="flex min-h-0 flex-col gap-4">
<div className="panel flex min-h-0 flex-1 flex-col overflow-hidden p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-[16px] font-extrabold">微信窗口实时画面</h2>
<p className="mt-1 text-[12px] text-muted">
{monitorEvent?.target_label ? `预览来源:${monitorEvent.target_label}` : `来自本地监控流:${MONITOR_ENDPOINT}/stream.mjpg`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="tag">{streamFps ? `${streamFps.toFixed(1)} FPS` : "FPS --"}</span>
<span className={`tag ${monitorEvent ? "text-primary" : ""}`}>{monitorEvent ? "在线" : "等待监控服务"}</span>
</div>
</div>
<div id="wechat-preview-frame" className="relative min-h-0 flex-1 overflow-hidden rounded-[14px] border border-line bg-black">
<img
src={streamUrl}
alt="微信窗口实时画面"
className="h-full w-full object-contain"
draggable={false}
onError={() => {
window.setTimeout(() => {
setStreamUrl(`${MONITOR_ENDPOINT}/frame.jpg?t=${Date.now()}`);
}, 1000);
}}
/>
<div className="absolute left-3 top-3 rounded-full bg-black/70 px-3 py-1 font-mono text-[12px] font-bold text-[#86efac]">
{streamFps ? `${streamFps.toFixed(1)} FPS` : "等待帧率"}
</div>
{annotation?.regions?.map((region) => {
const style = regionStyle(region);
if (!style) return null;
return (
<div
key={region.id}
className="pointer-events-none absolute rounded-[8px] border-2 border-[#22c55e] bg-[#22c55e]/10 shadow-[0_0_0_1px_rgba(0,0,0,.45)]"
style={style}
>
<div className="absolute -left-0.5 -top-6 rounded bg-[#22c55e] px-2 py-0.5 text-[11px] font-black text-black shadow">
{region.type || region.name}
</div>
</div>
);
})}
{streamError ? (
<div className="absolute inset-x-0 bottom-4 mx-auto w-fit max-w-[80%] rounded-full bg-black/75 px-4 py-2 text-center text-[12px] font-bold text-[#fca5a5]">
{streamError}
</div>
) : null}
</div>
</div>
</section>
<aside className="panel flex min-h-0 max-w-[400px] flex-col overflow-hidden p-4 max-lg:max-w-none">
<div className="mb-3 shrink-0">
<h2 className="text-[16px] font-extrabold">客户聊天记录</h2>
<p className="mt-1 text-[12px] text-muted">展示最近一次 LLM 读取到的对话和建议回复</p>
</div>
<div className="scrollbar-hidden min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
<div className="rounded-[14px] border border-line bg-surface2 p-3">
<div className="text-[11px] font-bold text-muted">最新客户消息</div>
<div className="mt-2 text-[14px] font-bold leading-6">{latestUserMessage}</div>
</div>
<div className="space-y-2">
{visibleMessages.length ? (
visibleMessages.map((message, index) => (
<div
key={`${message.role}-${message.content}-${index}`}
className={`rounded-[14px] p-3 ${message.role === "me" ? "bg-[var(--primary-soft)]" : "bg-surface2"}`}
>
<div className="mb-1 flex items-center gap-2 text-[11px] font-black text-muted">
{message.role === "me" ? <UserRound size={13} /> : <Bot size={13} />}
{message.sender || messageLabel(message.role)}
</div>
<div className="text-[13px] leading-5">{message.content}</div>
</div>
))
) : (
<div className="rounded-[14px] border border-dashed border-line p-4 text-center text-[12px] text-muted">
暂无聊天记录等待监测到新消息后读取
</div>
)}
</div>
<div className="rounded-[14px] border border-primary/30 bg-[var(--primary-soft)] p-3">
<div className="text-[11px] font-black text-primary">建议回复</div>
<div className="mt-2 text-[13px] leading-5">{replyText}</div>
</div>
{qaMessages.length ? (
<div className="space-y-2 border-t border-line pt-3">
{qaMessages.map((message, index) => (
<div key={`${message.role}-${index}`} className="rounded-[12px] bg-surface2 p-3 text-[12px] leading-5">
<div className="mb-1 font-black text-muted">{message.role === "user" ? "我" : "AI"}</div>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
))}
</div>
) : null}
</div>
<form onSubmit={submitQuestion} className="mt-3 flex shrink-0 items-center gap-2 border-t border-line pt-3">
<input
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="询问客户信息或当前对话..."
className="min-w-0 flex-1 rounded-[12px] border border-line bg-surface px-3 py-2 text-[13px] outline-none transition focus:border-primary"
/>
<button className="btn-primary h-10 shrink-0 px-3" type="submit" aria-label="发送问题">
<SendHorizontal size={16} strokeWidth={2.6} />
</button>
</form>
</aside>
</div>
);
}

View File

@ -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 (
<div className="grid gap-5">
<FormRow label="知识标题" required><input className="input-like" value={title} onChange={(event) => setTitle(event.target.value)} /></FormRow>
<FormRow label="标签" hint="输入标签后按回车生成,可点击标签移除。">
<div className="flex min-h-[40px] flex-wrap items-center gap-1.5 rounded-[4px] border border-lineStrong bg-surface px-2 py-1.5">
{tags.map((tag) => <button key={tag} type="button" aria-label={`移除标签 ${tag}`} className="tag" onClick={() => setTags((items) => items.filter((item) => item !== tag))}>{tag} <X className="inline" size={11} /></button>)}
<input
className="min-w-[160px] flex-1 bg-transparent px-2 text-[13px] outline-none"
placeholder="输入标签后回车"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
addTag();
}
}}
/>
</div>
</FormRow>
<FormRow label="说明"><textarea className="editor min-h-[120px]" defaultValue="用于回答售后退款范围、异常订单与客户安抚相关问题。" /></FormRow>
</div>
);
}
if (step === 1) {
return (
<div className="grid gap-5">
<button type="button" className="grid min-h-[190px] place-items-center rounded-[18px] border-2 border-dashed border-lineStrong bg-surface2 p-6 text-center hover:border-primary" onClick={() => inputRef.current?.click()}>
<span><Upload className="mx-auto text-primary" size={34} /><strong className="mt-4 block text-[16px]">点击选择知识来源</strong><span className="mt-2 block text-[12px] text-muted">支持 TXT / PDF / DOCX / PPT / PPTX / PNG / JPG / MP4</span></span>
</button>
<input ref={inputRef} className="hidden" type="file" accept=".txt,.pdf,.docx,.ppt,.pptx,image/*,video/*" multiple onChange={addFiles} />
<div className="panel divide-y divide-line overflow-hidden">
{files.map((file) => (
<div key={file.name} className="flex items-center gap-3 p-4">
<FileText className="shrink-0 text-primary" size={20} />
<div className="min-w-0 flex-1"><div className="truncate text-[13px] font-bold">{file.name}</div><div className="mt-1 text-[11px] text-muted">{file.size} · {file.status}</div></div>
<button type="button" className="btn-ghost" onClick={() => removeFile(file)}>移除</button>
</div>
))}
</div>
<label className="flex items-center gap-2 text-[13px] text-muted"><input type="checkbox" defaultChecked /> 导入前对敏感信息进行脱敏处理</label>
</div>
);
}
return (
<div className="grid gap-4">
<div className="panel p-4">
<div className="flex items-center justify-between gap-4">
<div>
<strong>知识来源已就绪</strong>
<p className="mt-1 text-[12px] text-muted"> {files.length} 个来源点击卡片预览内容</p>
</div>
<StatusPill label="可预览" tone="success" />
</div>
</div>
<SourceFileGrid files={files} onPreview={openFilePreview} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<WizardSteps items={steps} step={step} onChange={setStep} />
<WindowBody className="mx-auto w-full max-w-5xl">
{renderStep()}
<PageActions status={notice}>
{step > 0 ? <button className="btn-secondary" onClick={() => setStep((current) => current - 1)}>上一步</button> : null}
{/* <button className="btn-secondary" onClick={() => setNotice("草稿已保存(页面演示)")}>保存草稿</button> */}
{step < 2 ? <button className="btn-primary" onClick={next}>下一步</button> : <button className="btn-primary" onClick={saveKnowledge}>{mode === "update" ? "保存更新" : "发布知识"}</button>}
</PageActions>
</WindowBody>
{activeFile ? (
<SourcePreviewModal
file={activeFile}
description={descriptionDraft}
onDescriptionChange={setDescriptionDraft}
onSave={saveDescription}
onClose={closeFilePreview}
/>
) : null}
</div>
);
}
function SourceFileGrid({ files, onPreview }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{files.map((file) => {
const PreviewIcon = file.kind === "image" ? Image : file.kind === "video" ? Video : FileText;
return (
<button
key={file.name}
type="button"
aria-label={`预览 ${file.name}`}
className="panel group min-h-[124px] cursor-pointer p-4 text-left transition hover:border-primary hover:bg-surface2 focus:border-primary"
onClick={() => onPreview(file)}
>
<div className="flex items-start gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[8px] bg-[var(--primary-soft)] text-primary">
<PreviewIcon size={20} />
</span>
<span className="min-w-0 flex-1">
<strong className="block truncate text-[13px]">{file.name}</strong>
<span className="mt-1 block text-[11px] text-muted">{file.size} · {file.status}</span>
</span>
</div>
<span className="mt-4 flex items-center justify-between text-[11px] font-semibold text-muted">
<span>{file.kind === "image" ? "图片" : file.kind === "video" ? "视频" : "文本"}</span>
<span className="text-primary">{file.description ? "已填写描述" : "查看内容"}</span>
</span>
</button>
);
})}
</div>
);
}
function SourcePreviewModal({ file, description, onDescriptionChange, onSave, onClose, readOnly = false }) {
const isMedia = file.kind === "image" || file.kind === "video";
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-5" onMouseDown={onClose}>
<section
role="dialog"
aria-modal="true"
aria-labelledby="source-preview-title"
className="flex max-h-[88vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-line bg-page shadow-[0_24px_80px_rgba(0,0,0,.28)]"
onMouseDown={(event) => event.stopPropagation()}
>
<header className="flex items-center justify-between gap-4 border-b border-line bg-surface px-5 py-4">
<div className="min-w-0">
<h3 id="source-preview-title" className="truncate text-[16px] font-black">{file.name}</h3>
<p className="mt-1 text-[11px] text-muted">{file.size} · {file.status}</p>
</div>
<button type="button" className="icon-btn shrink-0" aria-label="关闭预览" onClick={onClose}><X size={18} /></button>
</header>
<div className="min-h-0 overflow-y-auto p-5">
{file.kind === "image" ? (
file.previewUrl
? <div className="grid min-h-[280px] place-items-center overflow-hidden rounded-[8px] border border-line bg-surface2 p-4"><img className="max-h-[52vh] max-w-full object-contain" src={file.previewUrl} alt={file.name} /></div>
: <div className="panel grid min-h-[280px] place-items-center text-[13px] text-muted"><Image size={30} />暂无可预览图片</div>
) : null}
{file.kind === "video" ? (
file.previewUrl
? <video className="max-h-[52vh] w-full rounded-[8px] bg-black" src={file.previewUrl} controls aria-label={`预览视频 ${file.name}`} />
: <div className="panel grid min-h-[280px] place-items-center text-[13px] text-muted"><Video size={30} />暂无可预览视频</div>
) : null}
{file.kind === "text" && file.error ? (
<div className="panel grid min-h-[280px] place-items-center p-6 text-center">
<div><AlertTriangle className="mx-auto text-error" size={30} /><strong className="mt-3 block text-[14px]">文档转换失败</strong><p className="mt-2 text-[12px] text-muted">{file.error}</p></div>
</div>
) : null}
{file.kind === "text" && !file.error ? (
<pre className="panel max-h-[58vh] overflow-auto whitespace-pre-wrap p-5 font-sans text-[13px] leading-7 text-text">{file.text || "文档正在等待转换为文本内容。"}</pre>
) : null}
{isMedia && readOnly ? (
<div className="mt-4">
<div className="text-[12px] font-black text-muted">内容描述</div>
<div className="panel mt-2 min-h-[72px] p-3 text-[13px] leading-6 text-text">
{description || "暂无内容描述"}
</div>
</div>
) : null}
{isMedia && !readOnly ? (
<div className="mt-4">
<FormRow label="内容描述" hint="描述图片或视频中的关键信息,保存后将随该来源保留。">
<textarea
className="editor min-h-[104px]"
value={description}
placeholder="填写画面内容、关键信息或使用场景"
onChange={(event) => onDescriptionChange(event.target.value)}
/>
</FormRow>
<div className="mt-3 flex justify-end"><button type="button" className="btn-primary" onClick={onSave}>保存描述</button></div>
</div>
) : null}
</div>
</section>
</div>
);
}
const detailTabs = ["概览", "知识库预览", "引用关系 5", "版本历史 7"];
export function KnowledgeDetailWindow() {
const knowledgeId = "kb-refund";
const [active, setActive] = useState("概览");
const [enabled, setEnabled] = useState(true);
const [activeFileName, setActiveFileName] = useState("");
const [version, setVersion] = useState(() => localStorage.getItem(knowledgeVersionKey(knowledgeId)) || "v1.7");
const activeFile = initialFiles.find((file) => file.name === activeFileName) || null;
useEffect(() => {
const applyVersion = (nextVersion) => {
if (typeof nextVersion === "string" && /^v\d+\.\d+$/.test(nextVersion)) setVersion(nextVersion);
};
const handleStorage = (event) => {
if (event.key === knowledgeVersionKey(knowledgeId)) applyVersion(event.newValue);
};
const channel = "BroadcastChannel" in window ? new BroadcastChannel(KNOWLEDGE_VERSION_CHANNEL) : null;
if (channel) {
channel.onmessage = (event) => {
if (event.data?.knowledgeId === knowledgeId) applyVersion(event.data.version);
};
}
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("storage", handleStorage);
channel?.close();
};
}, []);
async function updateKnowledge() {
const opened = await openWindow(`/window/knowledge-update?id=${knowledgeId}&version=${encodeURIComponent(version)}`);
if (opened) await closeWindow();
}
return (
<>
<WindowBody className="mx-auto w-full max-w-6xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div><div className="flex items-center gap-3"><h2 className="text-[24px] font-black">售后常见问题与退款边界</h2><span className="text-warning">{version}</span><StatusPill label={enabled ? "已生效" : "已停用"} tone={enabled ? "success" : "muted"} /></div><p className="mt-2 text-[12px] text-muted">更新于 2026-06-05 · 引用 82 </p></div>
</div>
<div className="mt-5"><WindowTabs items={detailTabs} active={active} onChange={setActive} /></div>
<div className="mt-5">
{active === "概览" ? <Overview enabled={enabled} setEnabled={setEnabled} version={version} onUpdate={updateKnowledge} /> : null}
{active === "知识库预览" ? <SourceFileGrid files={initialFiles} onPreview={(file) => setActiveFileName(file.name)} /> : null}
{active === "引用关系 5" ? <SimpleList items={["报价策略生成 · 智能体", "售后安抚助手 · 智能体", "销售冠军 Aileen · 员工", "客服主管 Mina · 员工", "售后自动回复 · 智能体"]} /> : null}
{active === "版本历史 7" ? <SimpleList items={[`${version} · 当前版本 · 2026-06-05`, "v1.6 · 修正数字商品退款边界 · 2026-05-28", "v1.5 · 新增异常订单流程 · 2026-05-16", "v1.4 · 调整安抚话术 · 2026-04-30"]} /> : null}
</div>
</WindowBody>
{activeFile ? (
<SourcePreviewModal
file={activeFile}
description={activeFile.description || ""}
readOnly
onClose={() => setActiveFileName("")}
/>
) : null}
</>
);
}
function Overview({ enabled, setEnabled, version, onUpdate }) {
return (
<div className="grid gap-5">
<div className="panel grid gap-1 p-5 md:grid-cols-2">
<InfoRow label="创建人" value="本机管理员" />
<InfoRow label="当前版本" value={version} />
<InfoRow label="生效时间" value="2026-06-05" />
<InfoRow label="最近检索" value="12 分钟前" />
</div>
<div>
<h3 className="mb-3 text-[14px] font-extrabold">内容摘要</h3>
<div className="panel p-5 text-[13px] leading-7 text-muted">涵盖退款期限数字商品限制异常订单处理和安抚话术边界正文按文档完整保存并通过 SQLite FTS5/BM25 执行相似度文本搜索</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Metric label="本周命中" value="32 次" />
<Metric label="引用智能体" value="3 个" />
<Metric label="绑定员工" value="2 个" />
<Metric label="正文字数" value="2.6 万" />
</div>
<div className="flex flex-wrap justify-end gap-2">
<button className="btn-primary" onClick={onUpdate}>更新知识库</button>
<button className={enabled ? "btn-danger" : "btn-secondary"} onClick={() => setEnabled((value) => !value)}>{enabled ? "停用" : "重新启用"}</button>
<button className="btn-secondary text-error">删除知识</button>
</div>
</div>
);
}
function SimpleList({ items }) {
return <div className="panel divide-y divide-line overflow-hidden">{items.map((item) => <div key={item} className="flex items-center justify-between p-4 text-[13px] font-semibold"><span>{item}</span><button className="btn-ghost">查看</button></div>)}</div>;
}

View File

@ -1,50 +1,64 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { logs } from "../data/mockData";
import Terminal from "../components/Terminal";
import TitleBar from "../components/TitleBar"; import TitleBar from "../components/TitleBar";
import EngineWorkbench from "./EngineWorkbench"; import { KnowledgeCreateWindow, KnowledgeDetailWindow } from "./KnowledgeWindows";
import SkillWindow from "./SkillWindow";
import { DistillStartWindow, EmployeeDetailWindow } from "./DistillWindows";
import EngineLogsWindow from "./EngineLogsWindow";
import PlaceholderWindow from "./PlaceholderWindow"; import PlaceholderWindow from "./PlaceholderWindow";
import SettingsWindow from "./SettingsWindow"; import SettingsWindow from "./SettingsWindow";
import { closeWindow } from "../utils/navigation";
export default function SecondaryWindow({ route, theme, setTheme, settingSection, setSettingSection }) { export default function SecondaryWindow({ route, theme, setTheme, settingSection, setSettingSection }) {
const routeParams = useMemo(() => new URLSearchParams(route.split("?")[1] || ""), [route]);
const title = useMemo(() => { const title = useMemo(() => {
if (route.includes("settings")) return "设置"; if (route.includes("settings")) return "设置";
if (route.includes("engine-workbench")) return "微信引擎工作台";
if (route.includes("engine-logs")) return "分身引擎日志"; if (route.includes("engine-logs")) return "分身引擎日志";
if (route.includes("knowledge-update")) return "更新知识库";
if (route.includes("knowledge-create")) return "新增知识库"; if (route.includes("knowledge-create")) return "新增知识库";
if (route.includes("knowledge-detail")) return "知识库详情"; if (route.includes("knowledge-detail")) return "知识库详情";
if (route.includes("skill")) return "skill技能详情"; if (route.includes("skill-update")) return "更新智能体";
if (route.includes("employee-detail")) return "员工蒸馏详情"; if (route.includes("skill-test")) return "测试智能体";
if (route.includes("skill-create")) return "新增智能体";
if (route.includes("skill-detail")) return "智能体详情";
if (route.includes("employee-detail")) return "员工详情";
if (route.includes("distill-start")) return "开始蒸馏"; if (route.includes("distill-start")) return "开始蒸馏";
return "二级窗口"; return "二级窗口";
}, [route]); }, [route]);
async function closeEngineWorkbench() {
await invoke("stop_agent").catch(() => {});
await invoke("stop_vision_stream").catch(() => {});
await emit("engine-state-changed", { enabled: false });
await closeWindow();
}
return ( return (
<div className="app-window flex h-dvh w-full flex-col overflow-hidden rounded-[12px]"> <div className="app-window flex h-dvh w-full flex-col overflow-hidden rounded-[8px]">
<TitleBar <TitleBar
title={title} title={title}
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
compact compact
onClose={route.includes("engine-workbench") ? closeEngineWorkbench : undefined}
/> />
<main className="content-canvas flex min-h-0 flex-1 flex-col overflow-hidden"> <main className="content-canvas flex min-h-0 flex-1 flex-col overflow-hidden">
{route.includes("settings") ? ( {route.includes("settings") ? (
<SettingsWindow active={settingSection} setActive={setSettingSection} /> <SettingsWindow active={settingSection} setActive={setSettingSection} />
) : route.includes("engine-workbench") ? (
<EngineWorkbench />
) : route.includes("engine-logs") ? ( ) : route.includes("engine-logs") ? (
<div className="flex h-full min-h-0 p-5"><Terminal title="完整运行日志" lines={[...logs, ...logs, ...logs]} tall /></div> <EngineLogsWindow />
) : route.includes("knowledge-update") ? (
<KnowledgeCreateWindow
mode="update"
knowledgeId={routeParams.get("id") || "kb-refund"}
currentVersion={routeParams.get("version") || "v1.7"}
/>
) : route.includes("knowledge-create") ? (
<KnowledgeCreateWindow />
) : route.includes("knowledge-detail") ? (
<KnowledgeDetailWindow />
) : route.includes("skill-update") ? (
<SkillWindow mode="update" />
) : route.includes("skill-test") ? (
<SkillWindow test />
) : route.includes("skill-create") ? (
<SkillWindow />
) : route.includes("skill-detail") ? (
<SkillWindow detail />
) : route.includes("distill-start") ? (
<DistillStartWindow />
) : route.includes("employee-detail") ? (
<EmployeeDetailWindow />
) : ( ) : (
<PlaceholderWindow title={title} route={route} /> <PlaceholderWindow title={title} route={route} />
)} )}

View File

@ -1,64 +1,88 @@
import { useState } from "react";
import { CheckCircle2, RotateCcw, Search, Trash2 } from "lucide-react";
import { employeeItems, knowledgeItems, skillItems } from "../data/mockData"; import { employeeItems, knowledgeItems, skillItems } from "../data/mockData";
import EditorBlock from "../components/EditorBlock"; import EditorBlock from "../components/EditorBlock";
import Field from "../components/Field"; import Field from "../components/Field";
import { SimpleSelect } from "../components/ui/select";
import SectionHeading from "../components/SectionHeading"; import SectionHeading from "../components/SectionHeading";
import Terminal from "../components/Terminal";
import ToggleRow from "../components/ToggleRow";
export default function SettingsContent({ active }) { export default function SettingsContent({ active }) {
if (active === "基础配置") { const [dirty, setDirty] = useState(false);
return ( const [saved, setSaved] = useState(false);
<div className="flex min-h-0 flex-1 flex-col gap-5"> const [testing, setTesting] = useState(false);
<SectionHeading title="大模型 key 配置" desc="兼容 OpenAI 协议" /> const [connected, setConnected] = useState(false);
<div className="grid gap-3"> const [customPrompt, setCustomPrompt] = useState("保持友好、简洁、可信赖。优先调用知识库和已授权员工能力。");
<Field label="模型厂家" value="豆包 / 火山引擎 / DeepSeek / 自定义" disabled />
<Field label="请求地址" value="https://api.example.com/v1" />
<Field label="API Key" value="sk-****************" secret />
<Field label="模型名称" value="doubao-pro-32k" />
</div>
<div className="flex gap-3">
<button className="btn-primary flex-1">保存</button>
<button className="btn-test flex-1">测试连接</button>
</div>
<Terminal
title="大模型请求记录"
lines={[
{ level: "info", message: "[ready] 等待测试连接" },
{ level: "warning", message: "[hint] 保存后由 Rust command 写入本地配置" },
]}
/>
</div>
);
}
if (active === "提示词配置") { function change() { setDirty(true); setSaved(false); }
return ( function save() { setDirty(false); setSaved(true); setTimeout(() => setSaved(false), 1800); }
<div className="flex min-h-0 flex-1 flex-col gap-5"> function testConnection() { setTesting(true); setConnected(false); setTimeout(() => { setTesting(false); setConnected(true); }, 700); }
<SectionHeading title="提示词配置" desc="固定提示词不可编辑,自定义提示词可保存。" />
<EditorBlock title="固定提示词" text="你是本地微信分身助手,需要遵守托管策略、回复边界与用户授权。" />
<EditorBlock title="自定义提示词" text="保持友好、简洁、可信赖。优先调用知识库和已授权员工能力。" fill />
<button className="btn-primary">保存提示词</button>
</div>
);
}
const listMap = { let content;
回复规则: ["免打扰模式", "自动加好友", "主动联系客户", "开启蒸馏", "拉黑用户名称列表", "白名单用户名称列表"], if (active === "基础配置") content = <BaseSettings testing={testing} connected={connected} onChange={change} onTest={testConnection} />;
skill管理: ["开启 skill 功能", ...skillItems.map((item) => item.name)], else if (active === "提示词配置") content = <PromptSettings value={customPrompt} onChange={(value) => { setCustomPrompt(value); change(); }} />;
员工管理: ["开启使用员工功能", ...employeeItems.map((item) => item.name)], else if (active === "回复规则") content = <ReplySettings onChange={change} />;
知识库管理: ["开启使用知识库功能", ...knowledgeItems.map((item) => item.title)], else if (active === "智能体管理") content = <ManagementSettings title="智能体功能" description="关闭总开关后保留单项配置,但运行时全部不生效。" items={skillItems.map((item, index) => ({ name: item.name, meta: item.type === "内置" ? `${item.type} · ${index === 2 ? "未测试" : "已测试"}` : `${item.type} · v${index + 1}.${index + 1} · ${index === 2 ? "未测试" : "已测试"}`, enabled: item.enabled }))} onChange={change} />;
托管配置: ["开启托管功能", "本地消息服务", "发送前人工确认", "异常自动暂停"], else if (active === "员工管理") content = <ManagementSettings title="员工能力" description="按员工控制是否允许引擎调用对应能力。" items={employeeItems.map((item, index) => ({ name: item.name, meta: `${item.version} · 评分 ${96 - index * 4} · ${item.domain}`, enabled: item.enabled }))} onChange={change} />;
}; else if (active === "知识库管理") content = <KnowledgeSettings onChange={change} />;
else content = <HostingSettings onChange={change} />;
return ( return (
<> <div className="flex min-h-0 flex-1 flex-col">
<SectionHeading title={active} desc="授权开关将在接入 Tauri command 后持久化到 sqlite 配置表。" /> <div className="min-h-0 flex-1 overflow-y-auto pr-1">{content}</div>
<div className="space-y-3"> <div className="mt-4 flex items-center justify-between gap-4 border-t border-line pt-4"><span className={`text-[12px] font-bold ${saved ? "text-primary" : dirty ? "text-warning" : "text-muted"}`}>{saved ? <><CheckCircle2 className="mr-1 inline" size={15} />配置已保存</> : dirty ? "● 有未保存修改" : "所有修改已保存"}</span><div className="flex gap-2"><button className="btn-secondary h-9 gap-2" disabled={!dirty} onClick={() => { setDirty(false); setSaved(false); }}><RotateCcw size={14} />恢复</button><button className="btn-primary h-9" disabled={!dirty} onClick={save}>保存配置</button></div></div>
{(listMap[active] || []).map((item, index) => ( </div>
<ToggleRow key={item} label={item} enabled={index % 3 !== 1} />
))}
</div>
<button className="btn-primary">保存配置</button>
</>
); );
} }
function BaseSettings({ testing, connected, onChange, onTest }) {
return (
<div className="grid gap-5">
<SectionHeading title="大模型配置" desc="兼容 OpenAI 协议API Key 只显示配置状态,不回显明文。" />
<div className="panel grid gap-4 p-5">
<label className="grid gap-2 text-[12px] font-black text-muted">
模型厂家
<SimpleSelect ariaLabel="选择模型厂家" defaultValue="豆包 / 火山引擎" onValueChange={onChange} options={["豆包 / 火山引擎", "DeepSeek", "自定义 OpenAI 协议"]} />
</label>
<Field label="请求地址" value="https://ark.cn-beijing.volces.com/api/v3" />
<div className="grid gap-2">
<span className="text-[12px] font-black text-muted">API Key</span>
<div className="flex gap-2"><div className="input-like flex-1 text-primary"> 已配置 ************************</div><button className="btn-secondary" onClick={onChange}>更换</button><button className="btn-secondary text-error" onClick={onChange}>删除</button></div>
</div>
<label className="grid gap-2 text-[12px] font-black text-muted">
模型名称
<SimpleSelect ariaLabel="选择模型名称" defaultValue="doubao-pro-32k" onValueChange={onChange} options={["doubao-pro-32k", "deepseek-chat"]} />
</label>
<label className="grid gap-2 text-[12px] font-black text-muted">请求超时毫秒<input className="input-like" defaultValue="8000" onChange={onChange} /></label>
<div className="flex items-center gap-3"><button className="btn-test" onClick={onTest} disabled={testing}>{testing ? "测试中" : "测试连接"}</button>{connected ? <span className="text-[12px] font-bold text-primary"> 连接成功 · 324ms · doubao-pro-32k</span> : <span className="text-[12px] text-muted">尚未测试当前配置</span>}</div>
</div>
</div>
);
}
function PromptSettings({ value, onChange }) {
return <div className="grid gap-5"><SectionHeading title="提示词配置" desc="固定提示词只读;自定义提示词限制 4,000 字。" /><EditorBlock title="固定提示词" locked text="你是本地微信分身助手,需要遵守托管策略、回复边界与用户授权。" /><div><div className="mb-2 flex justify-between text-[12px] font-black text-muted"><span>自定义提示词</span><span>{value.length} / 4000</span></div><textarea className="editor min-h-[250px]" value={value} maxLength={4000} onChange={(event) => onChange(event.target.value)} /></div><button className="btn-secondary justify-self-start">预览组合提示词</button></div>;
}
function ReplySettings({ onChange }) {
const [list, setList] = useState(["张三", "客户测试群"]);
const [name, setName] = useState("");
return <div className="grid gap-5"><SectionHeading title="回复规则" desc="配置自动回复边界、免打扰时间与黑白名单。" /><div className="panel divide-y divide-line">{[["免打扰模式", false], ["自动加好友", false], ["主动联系客户", false], ["开启蒸馏", true]].map(([label, enabled]) => <SwitchRow key={label} label={label} enabled={enabled} onChange={onChange} extra={label === "免打扰模式" ? "22:00 - 08:00" : label === "主动联系客户" ? "需要白名单授权" : ""} />)}</div><div className="panel p-5"><div className="flex gap-2"><button className="btn-primary h-8">白名单</button><button className="btn-secondary h-8">黑名单</button></div><div className="mt-4 flex gap-2"><div className="relative flex-1"><Search className="absolute left-3 top-3 text-muted" size={15} /><input className="input-like pl-9" placeholder="搜索或输入微信名称" value={name} onChange={(event) => setName(event.target.value)} /></div><button className="btn-secondary" onClick={() => { if (name.trim()) { setList((items) => [...items, name.trim()]); setName(""); onChange(); } }}>添加</button></div><div className="mt-4 divide-y divide-line">{list.map((item, index) => <div key={item} className="flex items-center justify-between py-3 text-[13px]"><span>{item}<small className="ml-3 text-muted">{index ? "联系人选择" : "手动添加"}</small></span><button className="icon-btn text-error" onClick={() => { setList((items) => items.filter((name) => name !== item)); onChange(); }}><Trash2 size={15} /></button></div>)}</div></div></div>;
}
function ManagementSettings({ title, description, items, onChange }) {
return <div className="grid gap-5"><SectionHeading title={title} desc={description} /><div className="panel overflow-hidden"><SwitchRow label={`开启${title}`} enabled onChange={onChange} strong />{items.map((item) => <SwitchRow key={item.name} label={item.name} extra={item.meta} enabled={item.enabled} onChange={onChange} />)}</div></div>;
}
function KnowledgeSettings({ onChange }) {
const entries = [...knowledgeItems.slice(0, 2).map((item, index) => ({ ...item, length: index ? "8,200 字" : "2.6 万字", enabled: true })), { title: "旧版报价说明", version: "v0.9", status: "已停用", length: "", enabled: false }];
return <div className="grid gap-5"><SectionHeading title="SQLite 文本搜索" desc="知识按完整文档保存,只使用 SQLite FTS5/BM25 文本相似度搜索。" /><div className="panel p-5"><SwitchRow label="开启使用知识库功能" enabled onChange={onChange} strong /><div className="grid gap-4 border-t border-line py-4 md:grid-cols-2"><Field label="搜索算法" value="SQLite FTS5 / BM25固定" disabled /><label className="grid gap-2 text-[12px] font-black text-muted">最大返回数量<input className="input-like" type="number" defaultValue="5" onChange={onChange} /></label></div><div className="flex flex-wrap gap-5 border-t border-line py-4 text-[13px]">{["标题", "标签", "完整正文"].map((item) => <label key={item} className="flex items-center gap-2"><input type="checkbox" defaultChecked onChange={onChange} />{item}</label>)}</div><div className="divide-y divide-line border-t border-line">{entries.map((item) => <SwitchRow key={item.title} label={item.title} extra={`${item.version} · ${item.status} ${item.length ? `· 正文 ${item.length}` : ""}`} enabled={item.enabled} disabled={item.status === "已停用"} onChange={onChange} />)}</div></div></div>;
}
function HostingSettings({ onChange }) {
return <div className="grid gap-5"><SectionHeading title="托管配置" desc="异常自动暂停优先于人工确认和自动发送。" /><div className="panel overflow-hidden"><SwitchRow label="开启托管功能" extra="Agent 可在规则和授权范围内处理微信消息" enabled={false} onChange={onChange} strong /></div><div className="panel divide-y divide-line"><SwitchRow label="本地消息服务" enabled onChange={onChange} /><SwitchRow label="发送前人工确认" enabled onChange={onChange} /><SwitchRow label="异常自动暂停" enabled onChange={onChange} /><div className="grid gap-4 p-4 md:grid-cols-2"><label className="grid gap-2 text-[12px] font-black text-muted">连续异常阈值<input className="input-like" defaultValue="3" onChange={onChange} /></label><label className="grid gap-2 text-[12px] font-black text-muted">单日消息最长等待<input className="input-like" defaultValue="30" onChange={onChange} /></label></div></div></div>;
}
function SwitchRow({ label, extra, enabled = false, disabled = false, onChange, strong = false }) {
const [checked, setChecked] = useState(enabled);
return <div className={`flex items-center justify-between gap-4 p-4 ${disabled ? "opacity-50" : ""}`}><div className="min-w-0"><div className={`text-[13px] ${strong ? "font-black" : "font-bold"}`}>{label}</div>{extra ? <div className="mt-1 truncate text-[11px] text-muted">{extra}</div> : null}</div><button className={`mini-switch ${checked ? "is-on" : ""}`} disabled={disabled} onClick={() => { setChecked((value) => !value); onChange?.(); }}><span /></button></div>;
}

159
src/windows/SkillWindow.jsx Normal file
View File

@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import AgentChatPanel from "../components/AgentChatPanel";
import StatusPill from "../components/StatusPill";
import { tagToneClasses } from "../data/mockData";
import { FormRow, Metric, PageActions, WindowBody, WindowTabs, WizardSteps } from "../components/WindowUI";
import { closeWindow, openWindow } from "../utils/navigation";
const editorSteps = ["基本信息", "提示词", "权限授权", "沙箱测试", "发布设置"];
const detailTabs = ["概览", "提示词", "权限授权", "测试记录"];
const DEFAULT_AGENT_PROMPT = "你是报价策略助手。根据客户意向、商品成本和已授权知识生成建议,不得突破配置的最低利润率。";
const AGENT_PROMPT_KEY = "agent-prompt:current";
export default function SkillWindow({ detail = false, mode = "create", test = false }) {
if (test) return <SkillTestWindow />;
return detail ? <SkillDetail /> : <SkillEditor mode={mode} />;
}
function SkillEditor({ mode }) {
const [step, setStep] = useState(0);
const [name, setName] = useState("报价策略生成");
const [tested, setTested] = useState(false);
const [enabled, setEnabled] = useState(false);
const [notice, setNotice] = useState("");
const isUpdate = mode === "update";
const [prompt, setPrompt] = useState(() => localStorage.getItem(AGENT_PROMPT_KEY) || DEFAULT_AGENT_PROMPT);
const [testStreaming, setTestStreaming] = useState(false);
const [tags, setTags] = useState([
{ label: "利润保护", tone: "tag-tone-green" },
{ label: "阶梯报价", tone: "tag-tone-orange" },
{ label: "优惠边界", tone: "tag-tone-purple" },
]);
const [tagInput, setTagInput] = useState("");
function addTag(event) {
if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
event.preventDefault();
const label = tagInput.trim();
if (!label) return;
if (tags.some((tag) => tag.label === label)) {
setTagInput("");
return;
}
const tone = tagToneClasses[Math.floor(Math.random() * tagToneClasses.length)];
setTags((items) => [...items, { label, tone }]);
setTagInput("");
}
function completeEditor() {
const savedPrompt = prompt.trim() || DEFAULT_AGENT_PROMPT;
localStorage.setItem(AGENT_PROMPT_KEY, savedPrompt);
setPrompt(savedPrompt);
setNotice(isUpdate ? "智能体已更新" : enabled ? "智能体已发布并启用" : "智能体已发布");
}
function renderStep() {
if (step === 0) return <div className="grid gap-5"><FormRow label="智能体名称" required><input className="input-like" value={name} onChange={(e) => setName(e.target.value)} /></FormRow><FormRow label="描述"><textarea className="editor min-h-[130px]" defaultValue="根据客户意向、商品成本和优惠边界生成报价建议。" /></FormRow><FormRow label="能力标签" hint="输入标签后按回车添加"><div className="flex min-h-[40px] flex-wrap items-center gap-1.5 rounded-[4px] border border-lineStrong bg-surface px-2 py-1.5">{tags.map((tag) => <span key={tag.label} className={`tag ${tag.tone}`}>{tag.label}</span>)}<input className="min-w-[140px] flex-1 bg-transparent px-2 text-[13px] outline-none" placeholder="输入标签后按回车" value={tagInput} onChange={(event) => setTagInput(event.target.value)} onKeyDown={addTag} /></div></FormRow></div>;
if (step === 1) return <div className="flex min-h-[360px] flex-1 flex-col"><label className="flex min-h-0 flex-1 flex-col gap-2"><span className="text-[12px] font-black text-muted">系统提示词</span><textarea className="editor min-h-[320px] flex-1" value={prompt} onChange={(event) => setPrompt(event.target.value)} /></label></div>;
if (step === 2) return <section><h3 className="mb-3 text-[14px] font-black">可访问知识库</h3><div className="panel divide-y divide-line">{["产品价格表 · 已生效 v3.2", "报价与折扣边界 · 已生效 v1.4", "售后退款政策 · 已生效 v1.7"].map((item, index) => <label key={item} className="flex items-center gap-3 p-4 text-[13px] font-semibold"><input type="checkbox" defaultChecked={index < 2} />{item}</label>)}</div></section>;
if (step === 3) return <AgentChatPanel onStreamingChange={setTestStreaming} onComplete={() => { setTested(true); setNotice("沙箱对话测试通过"); }} />;
return <div className="grid gap-5"><div className="panel p-5"><h3 className="text-[17px] font-black">{isUpdate ? "更新" : "发布"} {name}</h3><div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3"><Metric label="版本" value="v1.0" /><Metric label="授权知识" value="2 个" /><Metric label="测试" value={tested ? "已通过" : "未运行"} tone={tested ? "text-primary" : "text-warning"} /></div></div><FormRow label={isUpdate ? "更新说明" : "发布说明"}><textarea className="editor min-h-[120px]" defaultValue="首次发布报价策略生成智能体。" /></FormRow><label className="flex items-center gap-3 text-[13px]"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />发布后立即启用</label>{!tested ? <div className="rounded-xl border border-[#f3d6a6] bg-[#fff7e8] p-4 text-[12px] text-[#9a651b]">{isUpdate ? "保存更新" : "发布"}前必须至少完成一次成功测试</div> : null}</div>;
}
return <div className="flex min-h-0 flex-1 flex-col"><WizardSteps items={editorSteps} step={step} onChange={setStep} /><WindowBody className="mx-auto flex w-full max-w-5xl flex-col">{renderStep()}<PageActions status={notice}>{step > 0 ? <button className="btn-secondary" onClick={() => setStep((value) => value - 1)}>上一步</button> : null}{step < 4 ? <button className="btn-primary" onClick={() => setStep((value) => value + 1)} disabled={step === 3 && testStreaming}>下一步</button> : <button className="btn-primary" disabled={!tested} onClick={completeEditor}>{isUpdate ? "保存更新" : "发布智能体"}</button>}</PageActions></WindowBody></div>;
}
function SkillDetail() {
const [active, setActive] = useState("概览");
const [enabled, setEnabled] = useState(true);
const [prompt, setPrompt] = useState(() => localStorage.getItem(AGENT_PROMPT_KEY) || DEFAULT_AGENT_PROMPT);
useEffect(() => {
const handleStorage = (event) => {
if (event.key === AGENT_PROMPT_KEY && event.newValue) setPrompt(event.newValue);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
async function updateAgent() {
const opened = await openWindow("/window/skill-update?id=intent");
if (opened) await closeWindow();
}
function testAgent() {
void openWindow("/window/skill-test?id=intent");
}
async function deleteAgent() {
if (!window.confirm("确定删除“客户意图识别”智能体吗?此操作无法撤销。")) return;
localStorage.setItem("agent-deleted:intent", "true");
await closeWindow();
}
return (
<WindowBody className="mx-auto w-full max-w-6xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h2 className="text-[24px] font-black">客户意图识别</h2>
<StatusPill label={enabled ? "已启用" : "已关闭"} tone={enabled ? "success" : "muted"} />
</div>
<p className="mt-2 text-[12px] text-muted">内置智能体 · 更新于 2026-06-16 · 最近调用 16 </p>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button className="btn-primary" onClick={updateAgent}>更新智能体</button>
<button className="btn-secondary" onClick={testAgent}>测试智能体</button>
<button className="btn-secondary" onClick={() => setEnabled((value) => !value)}>{enabled ? "停用" : "启用"}</button>
<button className="btn-danger" onClick={deleteAgent}>删除</button>
</div>
</div>
<div className="mt-5"><WindowTabs items={detailTabs} active={active} onChange={setActive} /></div>
<div className="mt-5">
{active === "概览" ? (
<div className="grid gap-5">
<div className="panel p-5">
<h3 className="text-[16px] font-black">能力说明</h3>
<p className="mt-3 text-[13px] leading-7 text-muted">识别客户所处阶段购买意向核心异议并输出下一步跟进建议内置能力只读可启停和查看授权</p>
<div className="mt-4 flex flex-wrap gap-2">
<span className="tag tag-tone-green">线索分级</span>
<span className="tag tag-tone-orange">异议判断</span>
<span className="tag tag-tone-purple">下一步建议</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Metric label="最近调用" value="16 次" />
<Metric label="成功率" value="98.4%" />
<Metric label="平均耗时" value="1.2s" />
<Metric label="绑定知识" value="3 个" />
</div>
</div>
) : active === "提示词" ? (
<div className="panel min-h-[320px] p-5">
<h3 className="text-[14px] font-black">系统提示词</h3>
<p className="mt-4 whitespace-pre-wrap text-[13px] leading-7 text-text">{prompt}</p>
</div>
) : (
<SkillTab active={active} />
)}
</div>
</WindowBody>
);
}
function SkillTestWindow() {
return (
<WindowBody className="mx-auto flex w-full max-w-5xl flex-col">
<AgentChatPanel allowFiles initialInput="" />
</WindowBody>
);
}
function SkillTab({ active }) {
const content = {
权限授权: ["查询知识库 · 已授权", "读取当前会话 · 已授权", "直接发送消息 · 未授权"],
测试记录: ["2026-06-18 · 高意向询价 · 通过 · 1.2s", "2026-06-17 · 售后混合意图 · 通过 · 1.5s", "2026-06-16 · 空消息边界 · 通过 · 0.4s"],
}[active] || [];
return <div className="panel divide-y divide-line">{content.map((item) => <div key={item} className="p-4 text-[13px] font-semibold">{item}</div>)}</div>;
}

View File

@ -1,6 +0,0 @@
# 测试python服务
print("hello world")

View File

@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
@ -26,5 +27,11 @@ export default defineConfig({
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13', target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
overlay: fileURLToPath(new URL("./overlay.html", import.meta.url)),
},
},
}, },
}); });

View File

@ -0,0 +1,5 @@
numpy
Pillow
onnxruntime
pyautogui
pyperclip

View File

@ -1,4 +1,5 @@
import argparse import argparse
import platform
import time import time
from pathlib import Path from pathlib import Path
@ -123,11 +124,15 @@ def screen_click_position(x, y, screenshot_size):
) )
def paste_hotkey():
return ("command", "v") if platform.system() == "Darwin" else ("ctrl", "v")
def click_type_send(x, y, text): def click_type_send(x, y, text):
pyautogui.click(x, y) pyautogui.click(x, y)
time.sleep(0.2) time.sleep(0.2)
pyperclip.copy(text) pyperclip.copy(text)
pyautogui.hotkey("command", "v") pyautogui.hotkey(*paste_hotkey())
time.sleep(0.1) time.sleep(0.1)
pyautogui.press("enter") pyautogui.press("enter")

View File

@ -0,0 +1,50 @@
$ErrorActionPreference = 'Stop'
Set-Location -Path $PSScriptRoot
New-Item -ItemType Directory -Force -Path 'ouptsw' | Out-Null
$pidFile = Join-Path $PSScriptRoot 'ouptsw/wechat_algorithm_live.pid'
$outLogFile = Join-Path $PSScriptRoot 'ouptsw/wechat_algorithm_live.log'
$errLogFile = Join-Path $PSScriptRoot 'ouptsw/wechat_algorithm_live.err.log'
if (Test-Path $pidFile) {
$oldPid = (Get-Content $pidFile -Raw).Trim()
if ($oldPid) {
$oldProcess = Get-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue
if ($oldProcess) {
Write-Host "WeChat algorithm live monitor is already running: pid=$oldPid"
Write-Host 'Open http://127.0.0.1:8765/'
exit 0
}
}
}
$python = Join-Path $PSScriptRoot 'venv/Scripts/python.exe'
if (-not (Test-Path $python)) {
$python = Join-Path $PSScriptRoot '.venv/Scripts/python.exe'
}
if (-not (Test-Path $python)) {
$python = 'python'
}
$args = @(
'wechat_algorithm_live.py',
'--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',
'--typing-chunk-size', '2',
'--typing-delay', '0.08'
)
$process = Start-Process -FilePath $python -ArgumentList $args -WorkingDirectory $PSScriptRoot -RedirectStandardOutput $outLogFile -RedirectStandardError $errLogFile -PassThru -WindowStyle Hidden
Set-Content -Path $pidFile -Value $process.Id -NoNewline
Write-Host "WeChat algorithm live monitor started: pid=$($process.Id)"
Write-Host 'Open http://127.0.0.1:8765/'
Write-Host "Log: $outLogFile"
Write-Host "Error log: $errLogFile"

View File

@ -0,0 +1,26 @@
$ErrorActionPreference = 'Stop'
Set-Location -Path $PSScriptRoot
$pidFile = Join-Path $PSScriptRoot 'ouptsw/wechat_algorithm_live.pid'
if (-not (Test-Path $pidFile)) {
Write-Host 'WeChat algorithm live monitor is not running: pid file missing'
exit 0
}
$pidText = (Get-Content $pidFile -Raw).Trim()
if (-not $pidText) {
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
Write-Host 'WeChat algorithm live monitor is not running: pid file empty'
exit 0
}
$process = Get-Process -Id ([int]$pidText) -ErrorAction SilentlyContinue
if ($process) {
Stop-Process -Id $process.Id -Force
Write-Host "WeChat algorithm live monitor stopped: pid=$pidText"
} else {
Write-Host "WeChat algorithm live monitor is not running: pid=$pidText"
}
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue

View File

@ -1,6 +1,8 @@
import argparse import argparse
import base64 import base64
import json import json
import os
import platform
import signal import signal
import threading import threading
import time import time
@ -20,7 +22,19 @@ import tomllib
from wechat_window_live import capture_window_image, find_wechat_window, image_to_jpeg_bytes from wechat_window_live import capture_window_image, find_wechat_window, image_to_jpeg_bytes
DEFAULT_REGIONS = Path.home() / "Library/Application Support/com.tauri.dev/data/regions/wechat.json" def default_regions_path():
if platform.system() == "Windows":
appdata = os.environ.get("APPDATA")
if appdata:
return Path(appdata) / "com.tauri.dev/data/regions/wechat.json"
return Path.home() / "Library/Application Support/com.tauri.dev/data/regions/wechat.json"
def paste_hotkey():
return ("command", "v") if platform.system() == "Darwin" else ("ctrl", "v")
DEFAULT_REGIONS = default_regions_path()
DEFAULT_AGENT_CONFIG = Path(__file__).resolve().parent.parent / "agent" / "config.toml" DEFAULT_AGENT_CONFIG = Path(__file__).resolve().parent.parent / "agent" / "config.toml"
@ -384,9 +398,10 @@ def send_reply_text(reply_text, input_box, window, image_size, chunk_size=2, typ
pyautogui.click(point[0], point[1]) pyautogui.click(point[0], point[1])
time.sleep(0.15) time.sleep(0.15)
chunk_count = 0 chunk_count = 0
hotkey = paste_hotkey()
for chunk in text_chunks(text, chunk_size): for chunk in text_chunks(text, chunk_size):
pyperclip.copy(chunk) pyperclip.copy(chunk)
pyautogui.hotkey("command", "v") pyautogui.hotkey(*hotkey)
chunk_count += 1 chunk_count += 1
time.sleep(max(0.01, typing_delay)) time.sleep(max(0.01, typing_delay))
pyautogui.press("enter") pyautogui.press("enter")

View File

@ -1,5 +1,9 @@
import argparse import argparse
import ctypes
import ctypes.wintypes
import json import json
import os
import platform
import signal import signal
import subprocess import subprocess
import threading import threading
@ -14,7 +18,31 @@ import numpy as np
import onnxruntime as ort import onnxruntime as ort
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
import Quartz try:
from PIL import ImageGrab
except ImportError:
ImageGrab = None
try:
import Quartz
except ImportError:
Quartz = None
IS_WINDOWS = platform.system() == "Windows"
IS_DARWIN = platform.system() == "Darwin"
DWMWA_EXTENDED_FRAME_BOUNDS = 9
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
if IS_WINDOWS:
ctypes.windll.kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
ctypes.windll.kernel32.QueryFullProcessImageNameW.argtypes = [
ctypes.wintypes.HANDLE,
ctypes.wintypes.DWORD,
ctypes.wintypes.LPWSTR,
ctypes.POINTER(ctypes.wintypes.DWORD),
]
ctypes.windll.user32.EnumWindows.argtypes = [ctypes.c_void_p, ctypes.wintypes.LPARAM]
from ffmpeg_realtime_detect import ( from ffmpeg_realtime_detect import (
CLASS_NAMES, CLASS_NAMES,
@ -82,7 +110,134 @@ def emit(event):
print(json.dumps(event, ensure_ascii=False, separators=(",", ":")), flush=True) print(json.dumps(event, ensure_ascii=False, separators=(",", ":")), flush=True)
def _window_matches_wechat(owner, title):
owner = owner or ""
title = title or ""
owner_lower = owner.lower()
title_lower = title.lower()
return (
owner in {"微信", "WeChat"}
or owner_lower in {"wechat", "wechat.exe", "weixin", "weixin.exe"}
or "wechat" in owner_lower
or title == "微信"
or "wechat" in title_lower
)
def _windows_text(hwnd):
user32 = ctypes.windll.user32
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return ""
buffer = ctypes.create_unicode_buffer(length + 1)
copied = user32.GetWindowTextW(hwnd, buffer, length + 1)
if copied <= 0:
return ""
return buffer.value.strip()
def _windows_process_name(pid):
if not pid:
return ""
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return ""
try:
size = ctypes.wintypes.DWORD(32768)
buffer = ctypes.create_unicode_buffer(size.value)
ok = kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size))
if not ok:
return ""
return os.path.basename(buffer.value)
finally:
kernel32.CloseHandle(handle)
def _windows_rect(hwnd):
rect = ctypes.wintypes.RECT()
try:
result = ctypes.windll.dwmapi.DwmGetWindowAttribute(
hwnd,
DWMWA_EXTENDED_FRAME_BOUNDS,
ctypes.byref(rect),
ctypes.sizeof(rect),
)
except AttributeError:
result = -1
if result != 0:
ok = ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect))
if not ok:
return None
if rect.right <= rect.left or rect.bottom <= rect.top:
return None
return rect
def _find_windows_wechat_window():
user32 = ctypes.windll.user32
windows = []
enum_proc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
@enum_proc
def callback(hwnd, _lparam):
if not user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd):
return True
rect = _windows_rect(hwnd)
if rect is None:
return True
width = rect.right - rect.left
height = rect.bottom - rect.top
if width < 80 or height < 80:
return True
title = _windows_text(hwnd)
pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
owner = _windows_process_name(pid.value)
if not _window_matches_wechat(owner, title):
return True
windows.append(
{
"id": int(hwnd),
"owner": owner or "Windows",
"title": title,
"x": float(rect.left),
"y": float(rect.top),
"width": float(width),
"height": float(height),
"platform": "windows",
"pid": int(pid.value) if pid.value else None,
}
)
return True
user32.EnumWindows(callback, 0)
if not windows:
return None
windows.sort(key=lambda item: (item["owner"].lower() != "wechat.exe", -item["width"] * item["height"]))
return windows[0]
def _capture_windows_window_image(window_id):
if ImageGrab is None:
raise RuntimeError("Pillow ImageGrab is required for Windows window capture")
rect = _windows_rect(ctypes.wintypes.HWND(int(window_id)))
if rect is None:
return None
bbox = (rect.left, rect.top, rect.right, rect.bottom)
try:
image = ImageGrab.grab(bbox=bbox, all_screens=True)
except TypeError:
image = ImageGrab.grab(bbox=bbox)
return image.convert("RGB")
def find_wechat_window(): def find_wechat_window():
if IS_WINDOWS:
return _find_windows_wechat_window()
if not IS_DARWIN or Quartz is None:
return None
options = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements options = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
windows = Quartz.CGWindowListCopyWindowInfo(options, Quartz.kCGNullWindowID) or [] windows = Quartz.CGWindowListCopyWindowInfo(options, Quartz.kCGNullWindowID) or []
for window in windows: for window in windows:
@ -91,7 +246,7 @@ def find_wechat_window():
layer = window.get(Quartz.kCGWindowLayer, -1) layer = window.get(Quartz.kCGWindowLayer, -1)
if layer != 0: if layer != 0:
continue continue
if owner == "微信" or owner == "WeChat" or "wechat" in owner.lower() or title == "微信": if _window_matches_wechat(owner, title):
bounds = window.get(Quartz.kCGWindowBounds, {}) or {} bounds = window.get(Quartz.kCGWindowBounds, {}) or {}
return { return {
"id": int(window.get(Quartz.kCGWindowNumber)), "id": int(window.get(Quartz.kCGWindowNumber)),
@ -101,11 +256,16 @@ def find_wechat_window():
"y": float(bounds.get("Y", 0)), "y": float(bounds.get("Y", 0)),
"width": float(bounds.get("Width", 0)), "width": float(bounds.get("Width", 0)),
"height": float(bounds.get("Height", 0)), "height": float(bounds.get("Height", 0)),
"platform": "darwin",
} }
return None return None
def capture_window_image(window_id): def capture_window_image(window_id):
if IS_WINDOWS:
return _capture_windows_window_image(window_id)
if not IS_DARWIN or Quartz is None:
return None
image_ref = Quartz.CGWindowListCreateImage( image_ref = Quartz.CGWindowListCreateImage(
Quartz.CGRectNull, Quartz.CGRectNull,
Quartz.kCGWindowListOptionIncludingWindow, Quartz.kCGWindowListOptionIncludingWindow,