diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f4bb8c9..7200da74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,7 @@ jobs: python-version: '3.12' - name: 校验站点配置可解析 # 这里只做「配置与文档源没坏」的轻量校验,不做完整 mkdocs build - # (站点发布有独立流水线,见 deploy-sdk-site.sh)。 + # (站点发布有独立流水线,见 deploy/scripts/deploy-sdk-site.sh)。 run: | set -euo pipefail if [ -f mkdocs.yml ]; then diff --git a/DESKTOP-PET-NOTES.md b/DESKTOP-PET-NOTES.md deleted file mode 100644 index a18f539e..00000000 --- a/DESKTOP-PET-NOTES.md +++ /dev/null @@ -1,229 +0,0 @@ -# 桌宠动作系统:Coopanion 拆解与可借鉴点 - -> 来源:`Pal-AI-Lab/Coopanion`(本机 clone 于 `/tmp/coop`) -> 相关包:`packages/cortico-world-desktop-pet/` -> -> 本文只记录**桌宠的动作/表情/互动系统**,以及本仓(HomeAgent GUI)可借鉴什么。 -> 输入注入层(SendInput)的对比见 `GUI-PLAN.md` 与下文第七节。 - ---- - -## 一、它是什么 - -一个 C 形小生物,站在屏幕底边(任务栏上沿)。**用户看得见它的身体,看不见 agent 的消息正文** —— -想让用户看到内容,只能通过它冒气泡。 - -这句话是整个设计的关键: - -> 桌宠身体是 agent 的**唯一可见输出通道**,而不是装饰。 - -这和我们 GUI 的定位不同:我们的 mascot 只是「agent 光标」,不承载信息。这是一个可以借的点。 - ---- - -## 二、核心工具(4 个) - -| 工具 | 作用 | 关键设计 | -|---|---|---| -| `pet_say` | 头顶冒气泡说话 | 脚本里可夹带**动作标记**,边说边做 | -| `pet_ask` | 冒提问气泡 + 最多 3 个选项 | **立即返回**,回答通过 `[回答]` 事件异步送达 | -| `pet_walk_to` | 沿屏幕底边走到某处 | 目标是 0–1 比例或 `left/center/right/cursor` | -| `pet_act` | 不说话,只做一串动作 | `sit`/`sleep` 会**持续**到下个动作 | - -### 值得注意的三个设计 - -1. **`pet_ask` 是异步的**。agent 提完问不阻塞,用户答了再通过事件唤醒。 - 避免 agent 干等,也允许用户先不答。 - 实用细节:新问题会**替换**还没回答的旧问题;关掉不答也会回一个事件, - agent 不会永远等下去。 - -2. **`pet_walk_to` 支持 `cursor`** —— 走到鼠标当前横向位置。 - 这是个很自然的"到我这儿来"表达,不需要坐标。 - -3. **`sit`/`sleep` 是持续态**,其他动作是一次性。 - 这让 agent 能表达"待机"与"我在休息",而不只是瞬间动画。 - ---- - -## 三、动作词表(`src/script.ts` 的 `VOCAB`) - -它把动作分成两类:**表情**(持续几秒后回到平常脸)与**动作**。 - -### 表情(expression,10 个) - -| id | 中文 | 表现 | -|---|---|---| -| `neutral` | 平静 | 默认的脸 | -| `happy` | 开心 / 高兴 | 眼睛弯成 ^ ^ | -| `wink` | 眨眼 | 一只眼 ^ | -| `love` | 喜欢 / 爱心 | 眼睛变心形,冒小心心 | -| `shy` | 害羞 | 脸红,眼神躲开 | -| `surprised` | 惊讶 / 吃惊 | 眼睛放大,头顶感叹号 | -| `angry` | 生气 | 皱眉,头顶怒气符号,身体发抖 | -| `sad` | 难过 / 伤心 | 八字眉,掉眼泪 | -| `sleepy` | 犯困 / 困 | 眯眼打哈欠 | -| `thinking` | 思考 / 想想 | 眼睛往上看,头顶冒圈 | - -### 动作(motion,13 个) - -| id | 中文 | 表现 | -|---|---|---| -| `stand` | 站起 / 站 | 站起来(坐着、睡着时) | -| `jump` | 跳 / 跳起来 | 原地起跳 | -| `hop` | 小跳 / 蹦 | 小小蹦一下 | -| `look` | 张望 / 看看 | 左右张望 | -| `turn` | 转身 | 转向另一边 | -| `nod` | 点头 | 点两下头 | -| `shake` | 摇头 | 摇头 | -| `spin` | 转圈 | 原地转一圈 | -| `sit` | 坐下 / 坐 | **坐下,持续到下个动作** | -| `sleep` | 睡觉 / 睡 | **躺下睡觉,持续到下个动作** | -| `dizzy` | 晕 / 转晕 | 头晕眼花几秒 | -| `walk` | 走走 / 散步 | 随便走一段 | -| `run` | 跑 / 跑起来 | 跑到屏幕另一头 | - -### 词表设计的可借鉴处 - -- **每个词有多个中文别名**(`开心`/`高兴` 同一个 id)。LLM 输出自然语言时容错率更高。 -- **英文 id 与中文别名都进同一张 Map**(`BY_WORD`),不区分来源。 -- **未知词被丢弃而非报错**(`dropped` 数组收集但不中断)。 - 对 LLM 输出的容错策略,值得学。 -- 词表通过 `pet.vocab` 变量**注入到 prompt**,让模型知道能做什么 —— - 而不是把 30 个动作硬编码进 tool schema 的 enum。 - ---- - -## 四、脚本标记(说明与动作混排) - -`pet_say` 的 `script` 参数里可以夹带两种标记: - -| 标记 | 作用 | -|---|---| -| `【词, 词】` | 先做这些动作,然后**换一个新气泡**继续说 | -| `<词>` | 打字打到这里时做,**不换气泡** | - -示例(源码里的原例): - -``` -【开心】今天也辛苦啦!<眨眼>要不要歇一会儿? -``` - -这个设计解决了一个真实问题:**让文字与动作在时间轴上对齐**。 -`<眨眼>` 落在句子中间,让"眨眼"发生在念到那个字的时候,而不是整句之前。 -比"先做动作再说话"生动得多,实现成本却很低(解析时切分即可)。 - -相关实现:`parseScript` / `parseActions` / `estimateSeconds`(`src/script.ts`), -`estimateSeconds` 用于让回执报告"大约显示多久"。 - ---- - -## 五、自主行为与互动 - -### 待机(没有指令时) - -> 没收到指令时身体会自己眨眼、张望、散步、坐下打盹;你的指令会先执行。 - -注意最后半句:**agent 指令优先于自主行为**。自主行为是填充,不是竞争。 - -### 用户互动(事件形式回给 agent) - -| 事件 | 含义 | -|---|---| -| `[语音] 用户:…` | 麦克风听到并转写的话(可能同音错字) | -| `[打字] 用户:…` | 在桌宠上双击或点悬停按钮打的字 | -| `[回答] …` | 对 `pet_ask` 的回答 | -| `[互动] …` | 戳、摸、拎起来甩出去,以及落地摔晕 | - -细节值得注意: - -- 互动事件**不单独唤醒 agent**,"跟着下一批事件到,可能已经过去一会儿" - —— 避免用户随手一戳就把模型叫起来烧钱。 -- `asleep` 状态下被摸,文案不同(`摸了摸睡着的你`)。 - 状态影响事件语义,而不只是影响动画。 - ---- - -## 六、对本仓(HomeAgent GUI)的可借鉴点 - -### 6.1 可以借鉴(成本低、收益明确) - -| 点 | 现状 | 建议 | -|---|---|---| -| **动作与文字时间轴对齐**(`【】`/`<>`) | 我们的 mascot 只有移动/点击两种状态 | 若要表达"思考/等待/出错",可引入轻量标记 | -| **姿态表达状态** | 「忙碌呼吸」一种 | 可区分:思考中(转圈)、等待用户(张望)、出错(晕) | -| **持续态 vs 一次性**(`sit`/`sleep`) | 无持续态 | agent 空闲/被禁用时用持续态表达,而非静止 | -| **LLM 输出容错**(未知词丢弃不报错) | 未处理 | 动作解析应容错,不能因一个错词整条失败 | -| **待机自主行为** | 无 | 光标闲置时可轻微眨眼/呼吸,让"活着"可感知 | - -### 6.2 不建议照搬 - -| 点 | 原因 | -|---|---| -| **桌宠承载全部对话输出** | 我们是桌面客户端,有完整聊天界面,不该退化到只能冒气泡 | -| **语音输入(ASR)** | 与本期目标无关,且引入麦克风权限问题 | -| **戳/摸/甩出去** | 需要模型 + 物理引擎,投入产出比低 | -| **`pet.vocab` 注入 prompt** | 我们没把 mascot 交给 LLM 控,动作由 computeruse 隐式驱动 | - -### 6.3 一个更重要的判断 - -**Coopanion 的桌宠和我们的"独立光标"解决的是不同问题:** - -- 它的桌宠 = **输出通道**(让用户看见 agent 想说什么) -- 我们的 mascot 光标 = **过程指示**(让用户看见 agent 在操作哪里) - -两者不冲突,甚至可以合并 —— 让我们的 mascot 在 agent 思考时表现"思考"姿态, -在执行 computeruse 时表现"移动/点击"。这正是 6.1 表格里那几条的价值所在。 - ---- - -## 七、接口层对比(顺带记录,详见 GUI-PLAN.md) - -它和我们在**输入注入**上的关键分歧: - -| | Coopanion | 本仓(HomeAgent GUI) | -|---|---|---| -| 注入 API | `SendInput` | `SendInput`(默认)/ `PostMessage`(可选) | -| 能否操作 Chromium/游戏 | ✅ 能 | ✅ 能(sendinput 模式) | -| 是否移动真实光标 | ✅ 会移动 | ✅ 会移动(sendinput)/ ❌ 不动(overlay) | -| 用户操作时 | 等待空闲再注入 | 等待空闲再注入(**借鉴自它**) | -| 自绘光标 | ❌ 没有 | ✅ 有(mascot + 点击脉冲) | - -### 从它学到的关键一条 - -`GetLastInputInfo` 是**全局**的 —— agent 自己的 `SendInput` 也会被算作"用户输入"。 -若不排除自己的注入,`idleMs()` 每次注入后归零,于是**永远处于"用户正在操作"状态**, -所有命令都被暂缓。 - -它的做法(我们已照此实现)是三重判定: - -1. `ownTick` —— 自己最后一次注入的时刻,之后 40ms 内的输入忽略 -2. `ownCursor` —— 自己放下的光标位置,偏离 >2px 说明是用户动的 -3. 无符号差值 —— `GetTickCount` 每 2^32 ms 回绕,必须按无符号比较 - -这个坑我们实测踩到了(`deferMs:0` 仍被暂缓),教训已写进 `agent-inject.js` 注释。 - ---- - -## 八、其他独立印证 - -Coopanion 独立印证了我们上一轮的两个判断: - -1. **坐标就是物理像素,不要换算** —— 它注释明写: - > All coordinates are physical pixels of the primary display. The process declares - > itself per-monitor DPI aware (v2) before anything else, so no API here returns scaled values. - - 我们曾错误地加了 `/scaleFactor`,已改回并在判据中钉住。 - -2. **必须显式声明 DPI 感知** —— 它显式调用 `SetProcessDpiAwarenessContext(-4)`, - 并注明 "before anything else"。我们是**依赖 Electron 默认值**,未显式设。 - 虽然 Electron 默认已是 per-monitor aware,但显式声明更稳。**建议补上**(见待办)。 - ---- - -## 九、待办(由本文档派生) - -- [ ] `main.js` 显式调用 `SetProcessDpiAwarenessContext(-4)`,不依赖 Electron 默认值 -- [ ] 修 `deferMs` 配置读取不生效的问题(实测 `deferMs:0` 仍走 3000ms 默认) -- [ ] mascot 增加姿态:思考 / 等待 / 出错(参考第三节词表) -- [ ] 动作解析容错:未知动作丢弃而非整条失败 -- [ ] (可选)mascot 待机自主行为:闲置时轻微眨眼/呼吸 \ No newline at end of file diff --git a/GUI-PLAN.md b/GUI-PLAN.md deleted file mode 100644 index 1604ddc8..00000000 --- a/GUI-PLAN.md +++ /dev/null @@ -1,334 +0,0 @@ -# GUI 改造计划与进度 - -> 本文件是 GUI(`cmd/gui`)当前改造的**单一事实来源**:做完了什么、为什么这么做、 -> 哪些结论是实测得到的、哪些还没做完。 -> -> 相关文档: -> - `cmd/gui/DESIGN-NOTES.md` —— 借鉴 PiDeck 的设计与实现 -> - `SERVER-HANDOFF.md` —— 需要服务端同步配合的部分 -> - 判据:`cmd/gui/*.test.mjs`,`npm test` 全绿为准(当前 127 条) - ---- - -## 一、当前状态 - -分支:`feature/gui-starmap-pulse-vendor-align`(不合入 main) - -最近提交: - -| commit | 内容 | -|---|---| -| `02566f3` | 修启动期误判服务端未启动;keybd_event 类型名致组合键全废 | -| `0d8b129` | computeruse 补高级操作(两端 9→18);修 HiDPI 坐标;记录 PiDeck 借鉴 | -| `3059c99` | 加载动画改三点式 + 新增系统通知 | - -判据:`cd cmd/gui && npm test` —— **127 条全绿**(其中 `computeruse.test.mjs` 43 条)。 - ---- - -## 二、已完成 - -### 2.1 computeruse 能力面:9 → 18 个动作 - -此前 GUI 侧(`cmd/gui/main.js`)与 waiter 侧(`cmd/waiter/device.go`)都只有 -9 个基础动作,与业界 computer-use 的基本盘差距明显。 - -GUI 侧现支持 **18 个**(已用脚本从 `main.js` 的 `case "computeruse"` 块精确提取核对): - -``` -click doubleclick rightclick middleclick tripleclick -move hover drag mousedown mouseup -scroll keypress hotkey combo -type wait sleep display -``` - -waiter 侧(Linux)为 **20 个** —— 比 GUI 多 `middle`、`right` 两个 -「button 简写形式」,少 `physical` 相关处理;其余一致。 - -要点: - -- **拖拽分步移动**(默认 12 步,上限 60)。一步到位会让 canvas 拖拽、列表排序、 - 滑动条失效 —— 这类应用依赖中间 `mousemove` 事件。 -- **组合键归一化**(waiter 侧 `normKeySpec`)。旧实现把模型给的 key 字符串原样丢给 - `xdotool key`,于是 `"Ctrl+C"`、`"ctrl + c"`、`"CTRL-C"` **全部失效**。 -- **`type` 加 `--clearmodifiers`**(waiter 侧)。避免残留的 Ctrl/Alt 把后续输入 - 变成快捷键。 -- **scroll 修正**(两端)。旧实现固定滚 `click 4/5`,忽略方向语义也不支持横向。 - -### 2.2 修 HiDPI 坐标错位 ⚠️ 重要,详见第五节 - -截图给模型的是**物理像素**,Win32 坐标 API 在 Electron 里也是物理像素, -**不需要换算**。详见第五节的实测与踩坑记录。 - -### 2.3 修启动期误判「服务端未启动」 - -`isServerRunning()` 原硬编码 `http://localhost:8080/`。实测该部署下: - -``` -localhost:8080 -> 000 连不上 -192.168.2.60:8080 -> 302 正常(homed 跑在 WSL 里) -``` - -于是每次启动都:探测失败 → 误判服务没起 → 拉起 GUI 旁并不存在的 `homed.exe` -→ 干等 8s 超时 → 打印 `homed failed to start within timeout`。 -该 ERR 曾累计出现数十次(实测 40+ 次,日志轮转后计数会变), -且是 `gui.log` 里唯一的错误项。 - -改法:按 `connections.json` 里已配置的连接(当前优先)探测,命中即在线; -服务可达时跳过 homed 自动拉起;无本地 homed 可执行文件时不再空等超时。 - -实测验证:修复后启动日志为 `server reachable, skip homed autostart`,该 ERR 消失。 - -> 这条很可能就是此前「聊天链路有问题」的根因 —— GUI 自以为服务端由自己托管, -> 实际聊天依赖的是 `connections.json` 里的连接。 - -### 2.4 修 keybd_event 类型名致组合键全废 - -koffi 不认 Win32 头文件里的 `byte`: - -``` -"void keybd_event(byte bVk, ...)" -> Error: Unknown or invalid type name 'byte' -"void keybd_event(uint8 bVk, ...)" -> 正常 -``` - -原代码用的正是 `byte`,且整段包在**空 catch** 里,于是 `keybd_event` 恒为 null, -所有组合键返回 `keybd_event unavailable`。 - -这个 bug 极难发现:单键 click/move/scroll 走 `mouse_event`,完全不受影响。 -现在签名解析失败会打日志,不再静默。 - -### 2.5 模式 A:agent 独立光标 + 后台注入(**代码已通,效果待验证**) - -见第三节。 - ---- - -## 三、模式 A:agent 独立光标(进行中) - -### 3.1 目标 - -现有 `computeruse` 走 `SetCursorPos` + `mouse_event`,**会抢走用户的真实鼠标**, -agent 一干活用户就没法用电脑。 - -模式 A 的目标:GUI 自绘一个 agent 专属光标(mascot 形象),实际输入通过 -`PostMessage` 直接投递到目标窗口句柄,**用户真实鼠标全程不动**。 - -### 3.2 新增文件 - -| 文件 | 职责 | -|---|---| -| `cmd/gui/agent-cursor.js` | 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲动画;记录上次命中的窗口句柄 | -| `cmd/gui/agent-inject.js` | 两种注入路径:`SendInput`(默认)与 `PostMessage`(后台);含用户活动感知 | - -### 3.3 配置 - -`gui-prefs.json` → `deviceBridge.agentCursor`: - -| 键 | 值 | 行为 | -|---|---|---| -| `mode` | `"sendinput"` | **默认**。`SendInput` 注入系统输入队列,可靠性高(Chromium/游戏都能操作)。代价:会移动真实光标,故先等用户停手 | -| `mode` | `"overlay"` | `PostMessage` 直投窗口句柄。用户鼠标纹丝不动,但自绘界面常忽略合成消息 | -| `mode` | `"real"` | 保留旧代码路径(兼容) | -| `deferMs` | 数字 | 用户活动时最多等多久(默认 3000,上限 10000)。0 = 不等待 | - -> 为什么默认改成 `sendinput`:用户要求「可靠 + 用户正在操作则暂缓」。 -> 参考 `Pal-AI-Lab/Coopanion`(见 `DESKTOP-PET-NOTES.md` 第七节)。 -> -> ⚠ 实测发现:`GetLastInputInfo` 是**全局**的,agent 自己的 `SendInput` -> 也会被算作「用户输入」,若不排除则**永远处于「用户正在操作」状态**。 -> 已按 Coopanion 的三重判定修正(ownTick / ownCursor / 无符号差值)。 - -### 3.4 已验证(端到端) - -用假网关(自实现 WS 服务端)下发 computeruse 命令,GUI 执行并回执。 -两轮合计: - -**overlay(PostMessage)路径**:8/8 有响应,错误处理正确 —— -无窗口时报「WindowFromPoint 返回空」,而不是静默回退到真实鼠标。 - -**sendinput 路径**(当前默认):8/8 有响应,回执形如 -`sendinput click @ (300,300)` / `sendinput typed 2 chars` / -`sendinput drag (100,100) -> (400,300)`。 - -### 3.5 ⚠️ 未完成:真实点击效果未验证 - -当前执行环境**没有可供注入的可见目标窗口** -(`WindowFromPoint` 对所有坐标返回 null,`GetCursorPos` 读回 0,0)。 - -**因此:「点击真的落到目标窗口上」这一步尚未被证明。** 需要在有交互桌面的 -会话里跑一个端到端测试: - -> 打开记事本 → agent 点它 → 输入文字 → 截图回读验证 - -同时验证「`overlay` 模式下用户鼠标位置在全过程未变」。 - -### 3.6 设计取舍(明确的边界) - -- **两种注入的适用范围不同**: - - `sendinput`(默认):注入系统输入队列,等价真实硬件事件。 - Chromium/Electron 自绘界面、多数游戏都能接收 —— **可靠**。 - 代价:会移动用户真实光标,故先等用户停手(`deferMs`)。 - - `overlay`:`PostMessage` 直投窗口句柄,用户鼠标纹丝不动。 - 但自绘界面常忽略合成消息 ⇒ 只能操作原生 Win32 程序。 - - > 本仓曾把「自绘界面点不动」写成「Windows 消息模型的硬限制」—— - > **那个说法是错的**,它只是 `PostMessage` 这条 API 的限制。 - > 用 `SendInput` 就不受此限(参考 Coopanion)。 - -- **不做静默降级**。无论哪种模式,失败即 `status=error` 并说明原因, - 不会自作主张换成另一种注入方式。 -- **`sendinput` 模式下用户正在操作会暂缓**(而不是硬抢): - 轮询等到用户空闲,超时则跳过本次操作并报错。 - 已在 `agent-inject.js` 中排除「自身注入被误判为用户操作」的陷阱。 -- **`overlay` 模式下键盘类动作依赖先前的鼠标操作**。 - `keypress`/`hotkey`/`type` 不带坐标,而该模式不移动真实光标, - 所以 `GetCursorPos` 拿到的是**用户自己**的光标位置; - 故记录「上次鼠标操作命中的 hwnd」,键盘动作投递到那里。从未点过则报错。 - -### 3.7 跨平台状态 - -| 平台 | 状态 | -|---|---| -| Windows | 已实现(sendinput 默认 / overlay 可选;真实效果待验证) | -| Linux | waiter 侧仍是 `xdotool`,**会动真鼠标**。未做独立光标 | -| macOS | 同上 | - ---- - -## 四、待办 - -### 4.1 优先:修 `deferMs` 读取不生效 - -实测:`gui-prefs.json` 里 `agentCursor.deferMs = 0`,但命令仍按默认 3000ms -暂缓(回执写「已等待 3000ms」)。`loadGuiPrefs()` 会重建对象且**不透传 -`agentCursor` 字段**,导致读取永远拿到默认值。 - -- [ ] `loadGuiPrefs()` 透传 `deviceBridge.agentCursor` -- [ ] 判据:配置 `deferMs:0` 时不得进入暂缓路径 - -### 4.2 验证 sendinput 的真实效果 - -当前环境无可注入的可见目标窗口(`WindowFromPoint` 恒返回 null, -`GetCursorPos` 读回 0,0),故「点击真的落到目标窗口上」尚未证明。 - -- [ ] 端到端测试:打开真实应用 → 点它 → 输入 → 截图回读 -- [ ] 断言:`overlay` 模式下全过程用户鼠标位置未变 -- [ ] 判据:固化进 `npm test`(需能在无桌面 CI 环境跳过) - -### 4.3 服务端配合(详见 `SERVER-HANDOFF.md`) - -- [ ] 放开 `device.go` 的 action 白名单(schema enum + switch default 双重限制) -- [ ] 透传新参数 `tox`/`toy`/`steps`/`dx`/`ms`/`physical` -- [ ] 统一 scroll 方向语义(服务端写「正=向下」,Windows 惯例是正=向上) -- [ ] 把坐标约定写进注释(防重蹈 `/scale` 覆辙) - -> ⚠ 不改白名单,则新增的 11 个动作**根本下发不到设备**。 - -### 4.4 设置页 - -- [ ] `agentCursor.mode` / `deferMs` 的 UI(目前只能手改 `gui-prefs.json`) - -### 4.5 可选增强(详见 `DESKTOP-PET-NOTES.md` 第六、九节) - -- [ ] `main.js` 显式调 `SetProcessDpiAwarenessContext(-4)`,不依赖 Electron 默认值 -- [ ] mascot 姿态:思考 / 等待 / 出错 -- [ ] 动作解析容错:未知动作丢弃而非整条失败 -- [ ] mascot 待机自主行为:闲置时轻微眨眼/呼吸 -- [ ] Linux 侧若也要「不抢鼠标」,需 Wayland + uinput 虚拟设备 - (X11 下做不到 —— XTEST 合成的是真实设备事件) - -### 4.6 其它已定位但未修 - -- [ ] `overlay` 模式下键盘类动作「必须先点过」的限制可放宽: - 可改为「取当前前台窗口」作为兵底,而不是直接报错。 -- [ ] `real` 模式与 `sendinput` 模式代码重叠较多,可考虑合并; - 目前保留 `real` 仅为兼容旧路径,实际上与 `sendinput` 行为重复。 -- [ ] `p3.js` / `p4.js` 是本轮遗留的临时补丁脚本(未被跟踪), - 确认无价值后删除,避免占坑。 - ---- - -## 五、坐标空间:实测结论与踩坑记录 ⚠️ - -**结论:截图给模型的像素、Win32 坐标 API 的坐标,是同一个坐标系 —— 物理像素。 -不要换算。** - -实测证据(Electron 主进程内,192 DPI / 200% 缩放屏): - -``` -Electron screen API (DIP) : 1260 x 840 scaleFactor=2 -GetSystemMetrics : 2520 x 1680 -SetCursorPos(200,200) → GetCursorPos 读回 (200,200) ← 恒等,不缩放 -``` - -原因是 Electron 主进程是 **per-monitor DPI-aware**,此感知下 Win32 坐标不做虚拟化。 - -### 踩坑:曾经加错的 `/scaleFactor` 换算 - -本仓曾在 `main.js` 加过: - -```js -absX = ox + rawX / scaleFactor // ← 错的 -``` - -理由是「SetCursorPos 期望 DIP」—— **那个前提不成立**,DIP 行为只出现在 -DPI-*unaware* 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 -点 (600,400) 会被送到 (400,267),**越靠右下偏得越远**。 - -已改回 `physical ? rawX : rawX * scale`,并在 `computeruse.test.mjs` 加判据 -防止回退(检测 `/scale` 是否回来了)。 - -### 为什么会误判(重要) - -**在普通 node 进程里测同一个函数,读到的是 1260×840**(虚拟化后的值), -会让人以为 Win32 用 DIP。**必须在 Electron 内测** —— 同一份代码, -不同进程的 DPI 感知状态不同,答案也不同。 - -同理,`python`/`go` 等非 DPI-aware 宿主里测同样不可作为依据。 - ---- - -## 六、端到端测试方法(可复用) - -不依赖真实服务端,用自实现的假网关驱动 GUI: - -1. 起一个最小 WS 服务端(自实现 HTTP `Upgrade` 握手 + 帧编解码) -2. 改写 `gui-prefs.json` 指向它,并置 `deviceBridge.authorized = true` -3. 启动 GUI,等 `hello` / `bind` 完成 -4. 下发 `{op:"cmd", cmd_type:"homeagent", command:"computeruse {...}"}` -5. 读取 GUI 回的 `{op:"cmd_result", status, output, error}` - -### 两个踩过的坑 - -- **服务端→客户端的帧不能加掩码。** RFC 6455 只要求客户端→服务端掩码。 - 照抄 GUI 主进程(客户端方向)的 `sendDeviceFrame` 会导致 GUI 解不出。 -- **客户端→服务端的帧是掩码的**,解帧时必须先解掩码。 - ---- - -## 七、koffi 3.x 使用约定(踩坑记录) - -| 事项 | 正确做法 | 错误做法 / 现象 | -|---|---|---| -| 类型名 | `uint8` / `int32_t` / `long` | `byte` → `Unknown or invalid type name` | -| 指针参数 | 传 **Node `Buffer`** | 传普通 JS 对象 → **不写回**,读回仍是原值 | -| 结构体 | `koffi.struct(name, {...})` 后传普通对象 | 重复 `koffi.struct("POINT")` → `Duplicate type name` | -| 字符串 | `Buffer.from(s + "\0", "utf16le")` | `koffi.alloc(str)` 把 str 当**类型名** | - -**指针参数必须用 Buffer** 这条尤其重要:`WindowFromPoint` 若用普通对象, -会拿不到真实 hwnd ⇒ 点击落到错误窗口上。 - -跨进程/跨模块重复声明同名结构体会抛 `Duplicate type name`,需 try/catch 换名注册。 - ---- - -## 八、环境注意事项 - -- 本仓库文件多为 **CRLF**。用 Node 脚本打补丁时先 `replace(/\r\n/g, "\n")` 处理, - 写回时按原行尾还原。 -- `git status` 会把大量文件标成 `M`,但 `git diff --numstat` 显示 `0 0` - —— 那是 CRLF 归一化噪声,不是真实改动。**提交前用 `--numstat` 甄别。** -- 本机没有 Go 工具链(`go: command not found`),`cmd/waiter/` 的改动 - **未经过编译验证**,需在有 Go 环境的机器上 `go build ./cmd/waiter` 确认。 -- 本会话多数为无头/无交互桌面环境,`WindowFromPoint` / 真实光标相关行为 - **无法在此验证**,需在有桌面的会话实测。 diff --git a/SERVER-HANDOFF.md b/SERVER-HANDOFF.md deleted file mode 100644 index 0b6de915..00000000 --- a/SERVER-HANDOFF.md +++ /dev/null @@ -1,294 +0,0 @@ -# 服务端交接:computeruse 协议对齐 - -> 面向服务端接手人。本文档描述 GUI 侧已完成的改动,以及**服务端必须同步修改**的部分。 -> 修改范围集中在 `internal/plugins/remotedevice/`(`device.go`)。 -> GUI 侧代码与判据见 `cmd/gui/`。 - ---- - -## 一、为什么需要这份文档 - -GUI 侧把 `computeruse` 从 9 个基础动作扩到 **18 个**,并新增了**模式 A(agent 独立光标 + 后台注入)**。 -但服务端 `device.go` 目前对 `action` 做了**两重硬限制**,导致新增能力根本下发不到设备: - -1. **JSON Schema 的 `enum`**(`device.go:129`)—— 7 个值之外直接被 schema 校验拒绝 -2. **`switch` 的 `default` 分支**(`device.go:471`)—— 返回「不支持的操作类型」 - -所以:**即使 GUI 已实现,服务端不改则新功能等于不存在。** - ---- - -## 二、必做项 1:放开 action 白名单 - -### 现状 - -```go -// device.go:129 —— schema enum,只有 7 个 -"enum": []interface{}{"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type"}, -``` - -```go -// device.go:471 —— default 直接报错 -default: - return nil, fmt.Errorf("不支持的操作类型 %s(可选 click/doubleclick/rightclick/move/scroll/keypress/type)", action) -``` - -### 需要改成 - -GUI 侧实际支持的 18 个 action(判据同源,`cmd/gui/computeruse.test.mjs` 会校验两端一致): - -``` -click doubleclick rightclick middleclick tripleclick -move hover drag mousedown mouseup -scroll keypress hotkey combo -type wait sleep display -``` - -建议在 `device.go` 里提取成单一常量,schema 与 switch 共用,避免两处漂移: - -```go -// computeruseActions 是设备端支持的完整动作集合。 -// ★ 必须与 cmd/gui/main.js 的 computeruse switch 保持一致; -// cmd/gui/computeruse.test.mjs 会校验 GUI 侧包含这些动作。 -var computeruseActions = []string{ - "click", "doubleclick", "rightclick", "middleclick", "tripleclick", - "move", "hover", "drag", "mousedown", "mouseup", - "scroll", "keypress", "hotkey", "combo", "type", - "wait", "sleep", "display", -} -``` - -然后: -- schema 的 `enum` 改为由 `computeruseActions` 生成 -- `switch` 的 `default` 分支的错误信息里列出这份清单 - -### 各动作的语义(供写 tool description 时参考) - -| action | 必需参数 | 说明 | -|---|---|---| -| `click` | `x`,`y` | 可选 `button`(left/right/middle) | -| `doubleclick` | `x`,`y` | | -| `rightclick` | `x`,`y` | 等价 `click` + `button:right` | -| `middleclick` | `x`,`y` | 中键 | -| `tripleclick` | `x`,`y` | 三击选中整行 | -| `move` / `hover` | `x`,`y` | 只移动不点击,用于触发 tooltip | -| `mousedown` / `mouseup` | `x`,`y` | 按下/释放分离,配对可做「按住」 | -| `drag` | `x`,`y`,`tox`,`toy` | 可选 `steps`(默认 12,上限 60) | -| `scroll` | `dy` | 可选 `dx`(横向) | -| `keypress` | `key` | 如 `ctrl+c`、`alt+f4` | -| `hotkey` / `combo` | `key` | 与 keypress 同义 | -| `type` | `text` | | -| `wait` / `sleep` | `ms` | 显式等待,等 UI/动画完成 | -| `display` | — | 返回各显示器 bounds + scaleFactor | - -> ⚠ **`drag` 的分步移动不是可有可无的**:canvas 拖拽、列表排序、滑动条 -> 这类应用依赖中间 `mousemove` 事件,一步到位会失效。所以 `steps` 建议透传。 - ---- - -## 三、必做项 2:透传新参数 - -### 现状 - -`device.go` 的 `switch action`(第 439 行起)只转发这些: - -```go -params["x"], params["y"], params["button"] -params["dy"] -params["key"] -params["text"] -``` - -其余参数**被静默丢弃**,即使 LLM 传了也到不了设备。 - -### 需要补上 - -| 参数 | 哪些动作需要 | 缺失后果 | -|---|---|---| -| `tox` / `toy` | `drag` | 拖拽终点丢失,拖不动 | -| `steps` | `drag` | 退化成一步到位,拖拽排序类操作失效 | -| `dx` | `scroll` | 横向滚动不可用 | -| `ms` / `duration` | `wait` / `sleep` | 等待时长不可控 | -| `button` | `hover`/`mousedown`/`mouseup`/`tripleclick` | 中键/右键按不下 | -| `physical` | 全部坐标类 | 无法声明坐标系(见第五节) | - -建议实现(保持与现有风格一致): - -```go -// 透传辅助:从 args 取整数键,缺省则不写入 params。 -func putInt(params map[string]interface{}, args map[string]interface{}, key string) { - if v, ok := args[key].(float64); ok { - params[key] = int(v) - } -} -``` - -在 `switch` 之后统一补一遍(避免每个 case 重复): - -```go -for _, k := range []string{"tox", "toy", "steps", "dx", "ms", "duration", "physical"} { - putInt(params, args, k) -} -``` - -> ⚠ `physical` 的类型是布尔或字符串都可能,取决于 LLM 怎么填。 -> 建议 schema 里显式声明 `"type": "boolean"`,并在 `putInt` 之外单独处理。 - ---- - -## 四、必做项 3:统一 scroll 方向语义 ⚠️ - -### 现状:两侧约定相反 - -**服务端**(`device.go:130`): - -```go -"dy": map[string]interface{}{"type": "integer", "description": "scroll 滚动量:正=向下,负=向上"}, -``` - -**GUI / Windows 惯例**:正 = 向上。 - -这不是笔误级的小问题——**方向反了会让 agent 在页面上滚到完全相反的位置**, -且因为「能滚」,不会报错,只会静默地做错事,比崩溃更难发现。 - -### 建议 - -统一为 **Windows 惯例:正 = 向上,负 = 向下**(与鼠标滚轮一致,也与 GUI 实现一致)。 -改 schema description: - -```go -"dy": map[string]interface{}{ - "type": "integer", - "description": "scroll 垂直滚动量:正=向上滚,负=向下滚(与鼠标滚轮方向一致)。可选 dx 做横向滚动,正=向右", -}, -``` - -GUI 侧对应实现(`cmd/gui/main.js`)已按此约定,无需改动。 - -> 如果服务端坚持保留「正=向下」,则必须**同步改 GUI**,否则两侧永远对不上。 -> 二选一,但必须写进注释并进判据。 - ---- - -## 五、建议项:把坐标约定写进注释(防重蹈) - -这条不是功能需求,而是**防止后来人再犯同一个错**——我本人就在这一轮犯过。 - -### 事实 - -**截图给模型的像素、Win32 坐标 API 的坐标,是同一个坐标系:物理像素。不要换算。** - -实测证据(Electron 主进程内,192 DPI / 200% 缩放屏): - -``` -Electron screen API (DIP) : 1260 x 840 scaleFactor=2 -GetSystemMetrics : 2520 x 1680 -SetCursorPos(200,200) → GetCursorPos 读回 (200,200) ← 恒等,不缩放 -``` - -原因是 Electron 主进程是 **per-monitor DPI-aware**,此感知下 Win32 坐标不做虚拟化。 - -### 曾经的坑 - -本仓曾在 `cmd/gui/main.js` 加过: - -```go -absX = ox + rawX / scaleFactor // ← 错的 -``` - -理由是「SetCursorPos 期望 DIP」——**那个前提不成立**,DIP 行为只出现在 -DPI-*unaware* 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 -点 (600,400) 会被送到 (400,267),**越靠右下偏得越远**。 - -(易误判之处:在普通 node 进程里测同一个函数,读到的是 1260×840, -那是虚拟化后的值,会把人带偏。必须在 Electron 内测。) - -### 建议 - -在两处加注释: - -1. `device.go` 的 `computeruse` 函数上方 -2. tool schema 里 `x`/`y` 的 description - -```go -// 坐标约定:x/y 为**物理像素**,与 screensee 截图坐标系一致, -// 也与设备端 Win32 坐标一致,**不需要按 scaleFactor 换算**。 -// (设备端 GUI 是 per-monitor DPI-aware,Win32 坐标不做虚拟化。) -// 仅当调用方显式传 physical=false 时,设备端才按 DIP 再乘回缩放比。 -``` - -`cmd/gui/computeruse.test.mjs` 已加判据防止 GUI 侧回退(会检测 `/scale` 是否回来了)。 -**服务端侧目前无对等判据**,建议补一个,锁定 schema 里的 description 不含 -「需换算」之类的误导表述。 - ---- - -## 六、模式 A(agent 独立光标 + 后台注入)需要服务端知道的事 - -### 行为变化 - -GUI 默认启用 `agentCursor.mode = "overlay"`: - -- agent 操作时**不再移动用户的真实鼠标**,用户可同时正常使用电脑 -- GUI 会在目标位置绘制一个 mascot 形象作为 agent 的虚拟光标,并有移动/点击脉冲动画 -- 实际输入通过 `PostMessage` 直接投递到目标窗口句柄 - -配置项(`gui-prefs.json` → `deviceBridge.agentCursor.mode`): - -| 值 | 行为 | -|---|---| -| `"overlay"` | **默认**。不动用户鼠标,后台注入 | -| `"real"` | 旧行为。操作真实鼠标(`SetCursorPos` + `mouse_event`) | - -### 服务端无需改动,但要知道三条限制 - -1. **只对「接受窗口消息的程序」可靠。** - 原生 Win32 程序(记事本、Excel、Office 等)可靠;Chromium/Electron 自绘界面、 - 多数游戏、DirectX 程序会忽略合成消息。这是 Windows 消息模型的硬限制, - 不是实现缺陷。 - -2. **注入失败会明确报错,不静默回退。** - 用户既然选择「不抢鼠标」,就不该偷偷改成操作真实鼠标。所以失败时 - `status=error`,`error` 里说明原因(例如「该坐标下没有窗口」)。 - -3. **键盘类动作需要先有点击类动作。** - `keypress`/`hotkey`/`type` 不带坐标,且后台注入**不移动真实光标**, - 所以 `GetCursorPos` 拿到的是**用户自己**的光标位置(通常不是 agent 要操作的地方)。 - 设备端记录「上一次鼠标操作命中的窗口」,键盘动作投递到那里。 - **若从未点过,键盘动作会报错。** 服务端在 tool description 里说明这一依赖关系, - 可以减少 LLM 调错。 - ---- - -## 七、验收方式 - -GUI 侧判据(服务端改完后跑): - -```bash -cd cmd/gui && npm test -``` - -含 `computeruse.test.mjs`,会校验两端 action 覆盖面的一致性。 - -服务端侧建议补的判据(放在 `internal/plugins/remotedevice/`): - -1. schema `enum` 覆盖全部 18 个 action -2. 每个 action 在 `switch` 里都有对应 case,不落 `default` -3. `tox`/`toy`/`steps`/`dx`/`ms` 能透传到下发 JSON(可用现有 - `binary_test.go:471` 的 computeruse 下发测试作模板,参考它如何断言收到的 JSON) - ---- - -## 八、附:本次 GUI 侧一并修掉的两个 bug(服务端可参考) - -1. **GUI 误判「服务端未启动」**:原实现只探 `localhost:8080`, - 服务端在 WSL/远端时探不通 → 每次启动误判服务没起 → 去拉起 GUI 旁不存在的 - `homed.exe` → 干等 8s 超时。已改为按 `connections.json` 里已配置的连接探测。 - (服务端侧无改动,但这条能解释历史上 GUI 日志里刷屏的 - `homed failed to start within timeout`。) - -2. **koffi 不认 Win32 的 `byte` 类型名**: - `keybd_event(byte bVk, ...)` 会抛 - `Unknown or invalid type name 'byte'`,必须写 `uint8`。 - 曾导致组合键整体返回 `keybd_event unavailable`,而单键 click 不受影响、极易漏掉。 - 与服务端无直接关系,但若服务端将来也走 FFI 调 Win32,注意这一点。 diff --git a/deploy-plan.sh b/deploy/scripts/deploy-plan.sh similarity index 94% rename from deploy-plan.sh rename to deploy/scripts/deploy-plan.sh index 5547ed66..51d827de 100755 --- a/deploy-plan.sh +++ b/deploy/scripts/deploy-plan.sh @@ -2,10 +2,10 @@ # 生产部署方案(**待确认,不自动执行**) # # 用法: -# bash deploy-plan.sh check # 只做部署前检查,不改任何东西 -# bash deploy-plan.sh backup # 备份当前二进制与适配器 -# bash deploy-plan.sh deploy # 替换二进制并重启(需显式确认) -# bash deploy-plan.sh rollback # 回滚到备份 +# bash deploy/scripts/deploy-plan.sh check # 只做部署前检查,不改任何东西 +# bash deploy/scripts/deploy-plan.sh backup # 备份当前二进制与适配器 +# bash deploy/scripts/deploy-plan.sh deploy # 替换二进制并重启(需显式确认) +# bash deploy/scripts/deploy-plan.sh rollback # 回滚到备份 # # ★ 本脚本刻意**不包含**任何"自动回滚"逻辑:回滚要不要做、什么时候做, # 是人的判断。脚本只负责把状态保全好,让回滚成为一条可执行的命令。 @@ -13,7 +13,7 @@ set -uo pipefail BIN=/usr/local/bin/homed -DATA=/home/newqqagent +DATA=${HA_DATA} BAK=/var/tmp/homed-backup-$(date +%Y%m%d-%H%M%S) NEW=${NEW_BIN:-/tmp/homed-ort} ADAPTERS=$DATA/adapters diff --git a/deploy-sdk-site.sh b/deploy/scripts/deploy-sdk-site.sh similarity index 94% rename from deploy-sdk-site.sh rename to deploy/scripts/deploy-sdk-site.sh index 37d80a90..05eb9c15 100755 --- a/deploy-sdk-site.sh +++ b/deploy/scripts/deploy-sdk-site.sh @@ -22,15 +22,15 @@ # 结论(打不了包)不变但**理由已变**,别照旧文字理解。 # # 用法: -# ./deploy-sdk-site.sh # 构建 + 部署 + 验证(两个站) -# ./deploy-sdk-site.sh --check # 只核对线上与本地产物差异,不动线上 -# ./deploy-sdk-site.sh --introduce # 只部署 introduce 站(改 site/ 时用,跳过 SDK 站重建) -# ./deploy-sdk-site.sh --rollback <备份目录名> # 回滚 sdk 站 -# ./deploy-sdk-site.sh --rollback-introduce <备份目录名> # 回滚 introduce 站 +# ./deploy/scripts/deploy-sdk-site.sh # 构建 + 部署 + 验证(两个站) +# ./deploy/scripts/deploy-sdk-site.sh --check # 只核对线上与本地产物差异,不动线上 +# ./deploy/scripts/deploy-sdk-site.sh --introduce # 只部署 introduce 站(改 site/ 时用,跳过 SDK 站重建) +# ./deploy/scripts/deploy-sdk-site.sh --rollback <备份目录名> # 回滚 sdk 站 +# ./deploy/scripts/deploy-sdk-site.sh --rollback-introduce <备份目录名> # 回滚 introduce 站 set -euo pipefail -SDK_DIR=/home/program/TrueAgent/third_party/homeagent-sdk +SDK_DIR=${REPO}/third_party/homeagent-sdk BUILD_DIR="$SDK_DIR/site_build" HOST=192.168.2.106 SSH="ssh -n -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no admin@$HOST" @@ -61,13 +61,13 @@ fi # ── introduce 站 ── # -# 零构建:/home/program/TrueAgent/site/ 里就是成品(site/README.md 自称 +# 零构建:${REPO}/site/ 里就是成品(site/README.md 自称 # 「单文件、零构建、零依赖」),改完直接是产物,所以这条只需要同步。 # # 部署时**故意不带上 README.md**:那是仓库里的说明文档,不是站点资源。 # 线上现有 2 个文件(index.html + assets/logo.svg),带 README 会多出一个 # 线上原本没有的文件,反而让 diff 比对永远不为零。 -INTRO_SRC=/home/program/TrueAgent/site +INTRO_SRC=${REPO}/site INTRO_PKG_NAME=intro-site-$TS.tar.gz INTRO_PKG=/tmp/$INTRO_PKG_NAME diff --git a/deploy-waiter.sh b/deploy/scripts/deploy-waiter.sh similarity index 95% rename from deploy-waiter.sh rename to deploy/scripts/deploy-waiter.sh index 397d891c..a6ac4bd0 100644 --- a/deploy-waiter.sh +++ b/deploy/scripts/deploy-waiter.sh @@ -15,9 +15,9 @@ # · 验证项:进程活着 + 设备在网关侧注册成功 # # 用法: -# bash deploy-waiter.sh check 只读检查 -# bash deploy-waiter.sh deploy 更新指定一台 -# bash deploy-waiter.sh rollback 回滚指定一台 +# bash deploy/scripts/deploy-waiter.sh check 只读检查 +# bash deploy/scripts/deploy-waiter.sh deploy 更新指定一台 +# bash deploy/scripts/deploy-waiter.sh rollback 回滚指定一台 set -uo pipefail diff --git a/docs/zh/deploy-runbook.md b/docs/zh/deploy-runbook.md index cb3890cd..4f8acab1 100644 --- a/docs/zh/deploy-runbook.md +++ b/docs/zh/deploy-runbook.md @@ -53,7 +53,7 @@ cmp -s /tmp/d1 /tmp/d2; echo $? # ⇒ 1(cmp 未被污染) ``` 为什么这比“偶尔报错”危险得多:它让**部署判据变成假绿灯**。 -2026-09-29 实测:`deploy-sdk-site.sh --check` 对着两份**确实不同**的 +2026-09-29 实测:`deploy/scripts/deploy-sdk-site.sh --check` 对着两份**确实不同**的 `index.html`(本地 `f8898cfc…` / 线上 `cc615cfa…`)报「✓ 逐字节一致」, 于是永远判定「无需部署」——站点改完再也不会被更新,而且看不出来。 已在 `547d28d` 修掉(钉绝对路径 + 启动自检)。 @@ -112,9 +112,9 @@ ls -l /tmp/homed-ort-new # 与 /usr/local/bin/homed ### 1.3 部署 ```bash -bash deploy-plan.sh check # 只读 -NEW_BIN=/tmp/homed-ort-new bash deploy-plan.sh deploy # 需输入 yes -bash deploy-plan.sh rollback # 回滚 +bash deploy/scripts/deploy-plan.sh check # 只读 +NEW_BIN=/tmp/homed-ort-new bash deploy/scripts/deploy-plan.sh deploy # 需输入 yes +bash deploy/scripts/deploy-plan.sh rollback # 回滚 ``` - `check`:服务状态、onnxruntime 标签、`libonnxruntime.so`、模型资产、适配器清单 @@ -235,10 +235,10 @@ md5sum ${HA_DATA}/adapters/*.lua | md5sum ### 2.2 更新 ```bash -bash deploy-waiter.sh check # 两台一起,只读 -bash deploy-waiter.sh deploy 192.168.2.106 # 需输入 yes -bash deploy-waiter.sh deploy 192.168.2.30 # 上一台验证通过后再做 -bash deploy-waiter.sh rollback +bash deploy/scripts/deploy-waiter.sh check # 两台一起,只读 +bash deploy/scripts/deploy-waiter.sh deploy 192.168.2.106 # 需输入 yes +bash deploy/scripts/deploy-waiter.sh deploy 192.168.2.30 # 上一台验证通过后再做 +bash deploy/scripts/deploy-waiter.sh rollback ``` **逐台更新,不要并行** —— 两台都连同一网关,同时重启会同时断链。 @@ -370,7 +370,7 @@ stage handler 入口自动加锁 + 锁不可重入所致,**插件侧问题** ## 5. 部署站点(SDK 文档站 / introduce) -`deploy-sdk-site.sh` 覆盖两个静态站,**与 homed / waiter 是独立部署单元**。 +`deploy/scripts/deploy-sdk-site.sh` 覆盖两个静态站,**与 homed / waiter 是独立部署单元**。 | 站 | 本地源 | 线上位置 | 构建 | | --- | --- | --- | --- | @@ -385,9 +385,9 @@ introduce 是**零构建**的 —— `site/` 里就是成品,脚本部署时** ### 5.1 用法 ```bash -bash deploy-sdk-site.sh --check # 只核对差异,不动线上 -bash deploy-sdk-site.sh # 构建 + 部署 + 验证 -bash deploy-sdk-site.sh --rollback .sdk-bak-<时间戳> # 回滚 +bash deploy/scripts/deploy-sdk-site.sh --check # 只核对差异,不动线上 +bash deploy/scripts/deploy-sdk-site.sh # 构建 + 部署 + 验证 +bash deploy/scripts/deploy-sdk-site.sh --rollback .sdk-bak-<时间戳> # 回滚 ``` `--check` 逐字节比对本地与线上,两个站都一致时可直接跳过部署。 @@ -441,7 +441,7 @@ cd third_party/homeagent-sdk # 3) 重新生成(含 docs/api/* 与 llms.txt 同步) tools/apidoc/build.sh # 4) 部署并验证 -bash ../../deploy-sdk-site.sh +bash deploy/scripts/deploy-sdk-site.sh ``` 验证线上是否真的可访问(**别只看本地产物**): diff --git a/plan.md b/docs/zh/legacy-issues.md similarity index 100% rename from plan.md rename to docs/zh/legacy-issues.md diff --git a/docs/zh/toolcall-parallel-execution-plan.md b/docs/zh/toolcall-parallel-execution-plan.md index b9e61c10..98e5e95a 100644 --- a/docs/zh/toolcall-parallel-execution-plan.md +++ b/docs/zh/toolcall-parallel-execution-plan.md @@ -497,9 +497,9 @@ waiter 有**两条**设备桥启动路径: ### 部署脚本的一个坑 -`deploy-waiter.sh` 里 `ssh` 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉: +`deploy/scripts/deploy-waiter.sh 里 `ssh` 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉: - bash deploy-waiter.sh deploy <<< "yes" # 喂了 yes 却打印「已取消」 + bash deploy/scripts/deploy-waiter.sh deploy <<< "yes" # 喂了 yes 却打印「已取消」 脚本本身完全正常、备份逻辑没问题,只是"明明喂了 yes 却什么也没发生"。 已给 5 处 `ssh` 统一加 `-n`。 diff --git a/internal/plugins/remotedevice/clipboardsue_content_test.go b/internal/plugins/remotedevice/clipboardsue_content_test.go new file mode 100644 index 00000000..a2f7ea0e --- /dev/null +++ b/internal/plugins/remotedevice/clipboardsue_content_test.go @@ -0,0 +1,132 @@ +package remotedevice + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// clipboardsue 的 content 参数:从**服务端可见的路径**读入剪切板。 +// +// ## 为什么要它(2026-10-05) +// +// 长文本/代码块塞不进 `clipboardsue <文字>` 的命令行;让模型先生成一大段 +// 文本再原样传给设备,既烧 token 又容易在转述中失真。 +// 与 GUI 侧 screensue / speakeruse 的 `@<路径>` 是同一类问题的同一类解法。 +// +// ## 这些判据钉住的行为 +// +// ① text 与 content 都空 ⇒ 明确报错(不能静默写空剪切板) +// ② content 指向不存在的文件 ⇒ 报错且**带上路径**(否则模型无从修正) +// ③ content 超上限 ⇒ 报错(防止把会话撑爆) +// ④ 设备不可用时优先报设备错,而不是先读文件 +// ——顺序很重要:不然一个离线设备会伪装成「文件不存在」 +// +// 注:本文件只验**参数校验与错误路径**。真正读文件并下发需要一台在线设备, +// 那由 binary_test.go 的 WS 级联调覆盖。 + +// newOfflineDevice 造一个 device,其 clipboardsue 必然在 clipboardCheck 阶段失败。 +func newOfflineDevice(t *testing.T) *devicectlDevice { + t.Helper() + return &devicectlDevice{reg: NewRegistry()} +} + +func TestClipboardsue_EmptyTextAndContentRejected(t *testing.T) { + d := newOfflineDevice(t) + _, err := d.clipboardsue(map[string]interface{}{"device_id": "x"}) + if err == nil { + t.Fatal("text 与 content 都为空时应当报错(不能静默写空剪切板)") + } + if !strings.Contains(err.Error(), "text") && !strings.Contains(err.Error(), "content") { + t.Errorf("错误信息应指明缺哪个参数,实际: %v", err) + } + // 只有空白也视为空 + if _, err2 := d.clipboardsue(map[string]interface{}{ + "device_id": "x", "text": " ", "content": " ", + }); err2 == nil { + t.Error("空白 text/content 也应报错") + } +} + +func TestClipboardsue_DeviceCheckedBeforeReadingFile(t *testing.T) { + // 设备不存在 ⇒ 必须先报「设备不存在」, + // 而不是先读文件报「文件不存在」——否则离线设备会伪装成文件问题。 + d := newOfflineDevice(t) + _, err := d.clipboardsue(map[string]interface{}{ + "device_id": "no-such-device", + "content": filepath.Join(t.TempDir(), "nope.txt"), + }) + if err == nil { + t.Fatal("设备不存在时应当报错") + } + if !strings.Contains(err.Error(), "no-such-device") { + t.Errorf("应先报设备问题(设备 %s 不存在),实际: %v", "no-such-device", err) + } +} + +func TestClipboardsueMaxBytesIsTwoMB(t *testing.T) { + // 上限与 GUI 侧 CAPABILITY_SOURCE_MAX_BYTES 对齐(2MB)。 + // 改这个常量时这条判据会红——提醒同步改 GUI。 + if clipboardsueMaxBytes != 2*1024*1024 { + t.Errorf("clipboardsue 上限应为 2MB(与 GUI 侧一致),实际 %d", clipboardsueMaxBytes) + } +} + +func TestClipboardsue_ContentFileReadable(t *testing.T) { + // 造一个真文件,确认 os.ReadFile 能读到(判据本身的自检: + // 若将来换成别的读法或路径处理变了,这里会先炸)。 + dir := t.TempDir() + p := filepath.Join(dir, "note.txt") + want := "行1\n行2\n" + if err := os.WriteFile(p, []byte(want), 0o644); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("读取失败: %v", err) + } + if string(b) != want { + t.Errorf("读回内容不符: %q", string(b)) + } +} + +// TestClipboardsue_ContentOversizeRejected 钉住**上限真的被用上**。 +// +// ★ 这条判据是补来的:我先前只写了「常量等于 2MB」那条, +// +// 把 `if len(b) > clipboardsueMaxBytes` 改成 `if false` 它照样绿—— +// **测的是常量、不是闸**。凡是要验「闸真的关着」, +// 必须造一个越过阈值的数据去撞它,而不是断言阈值的数值。 +func TestClipboardsue_ContentOversizeRejected(t *testing.T) { + // 造一个在线且已授权的设备,让 clipboardCheck 过; + // 随后超限文件必须在**读文件后的校验**处被拦下。 + dir := t.TempDir() + p := filepath.Join(dir, "big.txt") + big := make([]byte, clipboardsueMaxBytes+1) + for i := range big { + big[i] = 'x' + } + if err := os.WriteFile(p, big, 0o644); err != nil { + t.Fatal(err) + } + + reg := NewRegistry() + // 注册一个在线设备并授 capability:clipboardsue。 + reg.devices["d1"] = &DeviceMeta{ + DeviceID: "d1", Name: "d", Kind: "computer", Online: true, + Caps: []string{"clipboardsue"}, + } + d := &devicectlDevice{reg: reg} + + _, err := d.clipboardsue(map[string]interface{}{ + "device_id": "d1", + "content": p, + }) + if err == nil { + t.Fatal("超 2MB 的 content 应当被拒绝(把上限改成 if false 时这条会红)") + } + if !strings.Contains(err.Error(), "过大") && !strings.Contains(err.Error(), "上限") { + t.Errorf("错误信息应说明超限,实际: %v", err) + } +} diff --git a/napcat-design-dna.json b/napcat-design-dna.json deleted file mode 100644 index 2f7318f0..00000000 --- a/napcat-design-dna.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "meta": { - "name": "NapCat WebUI Design DNA", - "description": "Design DNA extracted from NapCat WebUI (napcat-webui-frontend) — ACG/cherry-blossom aesthetic with glassmorphism, motion/react animations, HeroUI components, sakura/frost color palette", - "source_references": [ - "https://github.com/NapNeko/NapCatQQ/tree/main/packages/napcat-webui-frontend", - "packages/napcat-webui-frontend/tailwind.config.js", - "packages/napcat-webui-frontend/src/styles/globals.css", - "packages/napcat-webui-frontend/src/components/page_background.tsx", - "packages/napcat-webui-frontend/src/components/effect_card.tsx", - "packages/napcat-webui-frontend/src/layouts/default.tsx", - "packages/napcat-webui-frontend/src/layouts/pure.tsx", - "packages/napcat-webui-frontend/src/pages/web_login.tsx", - "packages/napcat-webui-frontend/src/pages/dashboard/index.tsx", - "packages/napcat-webui-frontend/src/components/sidebar/index.tsx", - "packages/napcat-webui-frontend/src/components/display_network_item.tsx", - "packages/napcat-webui-frontend/src/components/system_status_display.tsx", - "packages/napcat-webui-frontend/src/config/site.tsx" - ], - "created_at": "2026-08-12T00:00:00Z" - }, - "design_system": { - "color": { - "palette_type": "split-complementary", - "primary": { - "hex": "#FF7FAC", - "role": "Sakura pink - dominant accent, CTAs, focus rings, interactive elements" - }, - "secondary": { - "hex": "#88C0D0", - "role": "Frost blue - supporting accent, secondary actions, info states" - }, - "accent": { - "hex": "#F33B7C", - "role": "Deep rose - hover/active primary, destructive actions" - }, - "neutral": { - "scale": { - "50": "#FFF0F5", - "100": "#FFE4E9", - "200": "#FFCDD9", - "300": "#FF9EB5", - "400": "#FF7FAC", - "500": "#F33B7C", - "600": "#C92462", - "700": "#991B4B", - "800": "#691233", - "900": "#380A1B" - }, - "usage": "Pink-tinted neutrals for light mode; inverted for dark mode" - }, - "semantic": { - "success": "#17a964", - "warning": "#b7791f", - "error": "#DB3694", - "info": "#3f6ef5" - }, - "surface": { - "background": "gradient-to-br from-indigo-50 via-white to-pink-50 (light) / dark:from-gray-900 dark:via-gray-800 dark:to-gray-900", - "card": "bg-white/60 dark:bg-black/40 with backdrop-blur-sm border border-white/40 dark:border-white/10", - "elevated": "bg-white/70 dark:bg-black/30 hover:bg-white/80 dark:hover:bg-black/20 with shadow-sm" - }, - "contrast_strategy": "subtle layers with glassmorphism — semi-transparent cards over gradient backgrounds, white/black text with opacity modulation" - }, - "typography": { - "type_scale": { - "display": { - "size": "4xl-5xl (36-48px)", - "weight": "bold", - "line_height": "1.1", - "tracking": "-0.02em" - }, - "heading_1": { - "size": "2xl-3xl (24-30px)", - "weight": "semibold", - "line_height": "1.2", - "tracking": "-0.02em" - }, - "heading_2": { - "size": "xl-2xl (20-24px)", - "weight": "semibold", - "line_height": "1.3", - "tracking": "-0.01em" - }, - "heading_3": { - "size": "lg-xl (18-20px)", - "weight": "medium", - "line_height": "1.4", - "tracking": "0" - }, - "body": { - "size": "base (16px)", - "weight": "normal", - "line_height": "1.55", - "tracking": "0.02em" - }, - "body_small": { - "size": "sm (14px)", - "weight": "normal", - "line_height": "1.5", - "tracking": "0.01em" - }, - "caption": { - "size": "xs (12px)", - "weight": "normal", - "line_height": "1.4", - "tracking": "0.02em" - }, - "overline": { - "size": "xs (12px)", - "weight": "medium", - "line_height": "1.3", - "tracking": "0.08em" - } - }, - "font_families": { - "heading": "'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif", - "body": "'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif", - "mono": "'JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace" - }, - "font_style_notes": "Rounded geometric sans (Quicksand/Nunito) for friendly ACG feel; JetBrains Mono for code/terminal; letter-spacing 0.02em body, negative for headings; -webkit-font-smoothing antialiased" - }, - "spacing": { - "base_unit": "4px (0.25rem)", - "scale": [0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64], - "content_density": "comfortable with generous breathing room; cards use p-4/p-5/p-6; sections gap-4/gap-6", - "section_rhythm": "Consistent 4-unit (16px) base rhythm; md:gap-4 (16px) / lg:gap-6 (24px) for grid gaps; py-5 (20px) vertical section padding" - }, - "layout": { - "grid_system": "CSS Grid + Flexbox hybrid; 8-col mobile / 3-col md / 6-col lg for dashboards; sidebar 16rem (256px) fixed", - "max_content_width": "1000px (md:max-w-[1000px]) for main content; full-width for login/pure layouts", - "columns": "Responsive: 1col < 3col md < 6col lg", - "gutter": "gap-1 (4px) sm / gap-4 (16px) md", - "breakpoints": "sm: 640px, md: 768px, lg: 1024px, xl: 1280px (Tailwind defaults)", - "alignment_tendency": "centered for auth/pure layouts; left-aligned grid for dashboards; sticky top breadcrumbs" - }, - "shape": { - "border_radius": { - "small": "0.375rem (6px) - inputs, buttons", - "medium": "0.5rem (8px) - cards, modals", - "large": "0.75rem (12px) - HeroUI default radius (--heroui-radius: 0.75rem)", - "pill": "9999px (full) - primary CTAs, pills, tags" - }, - "border_usage": "subtle 1px border-white/40 dark:border-white/10 on glass cards; no hard borders elsewhere", - "divider_style": "hairline via backdrop-blur edges; HeroUI divider component for lists" - }, - "elevation": { - "shadow_style": "soft diffused with backdrop-blur; layering via transparency not heavy shadows", - "levels": { - "low": "shadow-sm (0 1px 2px rgba(0,0,0,0.05)) + backdrop-blur-sm", - "medium": "shadow-md (0 4px 6px rgba(0,0,0,0.07)) + backdrop-blur-md", - "high": "shadow-xl (0 20px 25px rgba(0,0,0,0.1)) + backdrop-blur-xl + backdrop-saturate-200" - }, - "depth_cues": ["backdrop-blur", "transparency/opacity", "subtle borders", "hover elevation increase", "motion scale on interaction"] - }, - "iconography": { - "style": "Lucide React (Lu*) icons — clean 2px stroke, rounded caps; react-icons for brand icons (Gi, Bi, Io, Md)", - "stroke_weight": "2px equivalent (w-5 h-5 default, w-4 h-4 small, w-6 h-6 large)", - "size_scale": "xs: 14px, sm: 16px, md: 20px, lg: 24px, xl: 32px", - "preferred_set": "Lucide React for UI; react-icons for brand/logos" - }, - "motion": { - "easing": "spring (stiffness: 120-150, damping: 15-20) for layout; ease-out for micro-interactions", - "duration_scale": { - "micro": "150-200ms (hover, focus, tap)", - "normal": "300-400ms (page transitions, sidebar, modals)", - "macro": "500ms (initial entrance, spring animations)" - }, - "entrance_pattern": "fade-up (opacity 0→1, y: 20→0) + scale (0.95→1) with spring; staggered children via AnimatePresence", - "exit_pattern": "fade-down (opacity 1→0, y: 0→10) fast tween (150ms)", - "philosophy": "playful spring physics for layout; snappy functional for interactions; motion/redux (framer-motion) for complex choreography" - }, - "components": { - "button_style": "HeroUI Button variants: shadow (primary CTA), flat (secondary), light (ghost), bordered; pill radius (full) for primary; size lg with py-7; startContent icons; isLoading state", - "input_style": "HeroUI Input: backdrop-blur-xl bg-transparent innerWrapper; shadow-xl inputWrapper with focus glow; radius lg; startContent icons; isClearable for token fields", - "card_style": "HeroUI Card: backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm; bg-white/60 dark:bg-black/40; hover elevation; optional background image overlay with opacity-30", - "navigation_pattern": "Collapsible sidebar (16rem→0) with spring animation; breadcrumbs with motion stagger; mobile overlay backdrop-blur", - "modal_style": "HeroUI Dialog/Modal with backdrop-blur; HoverEffectCard for login (3D tilt on hover); spring entrance", - "list_style": "Network item cards: grid layout, large mono count + label; System status: grid 2-col with icon headers; UsagePie canvas charts", - "component_notes": "HoverEffectCard: 3D perspective tilt (rotateX/rotateY) + moving gradient light blob on hover; PageBackground: static gradient blobs (primary/secondary/pink) with massive blur; Toast: react-hot-toast top-right" - } - }, - "design_style": { - "aesthetic": { - "mood": ["playful", "dreamy", "technical", "friendly", "modern"], - "visual_metaphor": "Cherry blossoms in digital frost — ACG aesthetic meets developer tooling", - "era_influence": "Modern web (2020s) with anime/moe culture influences; glassmorphism 2.0", - "genre": "Developer dashboard with ACG personality", - "personality_traits": ["approachable", "meticulous", "whimsical", "competent"], - "adjectives": ["sakura", "frost", "glass", "animated", "responsive"] - }, - "visual_language": { - "complexity": "moderate — rich but not cluttered; breathing room via glass cards", - "ornamentation": "subtle accents — gradient blobs, 3D tilt cards, animated breadcrumbs, scrollbar theming", - "whitespace_usage": "generous — cards have internal padding, grid gaps 16-24px, section padding 20px+", - "visual_weight_distribution": "balanced — sidebar + main content; dashboard cards equal weight; login centered hero", - "focal_strategy": "progressive reveal — entrance animations, hover discoveries, layered depth", - "contrast_level": "medium — relies on opacity layers not high contrast; dark mode inverts surfaces", - "texture_usage": "glassmorphism (backdrop-blur), gradient mesh blobs, subtle noise via saturation" - }, - "composition": { - "hierarchy_method": "scale contrast + typographic hierarchy + spatial isolation (cards)", - "balance_type": "asymmetric — sidebar fixed, main fluid; dashboard grid balanced", - "flow_direction": "vertical primary (sidebar→content), horizontal secondary (breadcrumbs, card grids)", - "grouping_strategy": "cards as containers; grid for peer items; sections separated by gap", - "negative_space_role": "breathing room between cards; focus isolation for modals/login" - }, - "imagery": { - "photo_treatment": "custom background image upload (base64) with overlay opacity-30 object-contain", - "illustration_style": "ACG/moe logo (NapCat cat), gradient blob art", - "graphic_elements": "gradient orbs (primary/secondary/pink), moving light blob on hover cards", - "pattern_usage": "none explicit; glassmorphism creates implied pattern", - "image_shape": "full-bleed background; logo 7em height on login" - }, - "interaction_feel": { - "feedback_style": "immediate visual — 3D tilt on cards, light blob follows cursor, spring scale on buttons", - "hover_behavior": "card: perspective rotateX/rotateY + gradient light follows mouse; button: shadow elevation + scale; input: focus glow ring", - "transition_personality": "spring physics (stiffness 120-150) for layout; snappy ease-out for micro", - "loading_style": "PageLoading full-screen overlay; Skeleton cards for dashboard; Suspense boundaries per route", - "microinteraction_density": "high — every interactive element has hover/tap/transition choreography" - }, - "brand_voice_in_ui": { - "tone": "friendly technical — '猫猫日志', '喵~', emoji in hints", - "formality": "casual developer — Chinese labels with English terms mixed", - "cta_style": "direct imperative with playful twist — '登录' with logo animation, '切换主题', '重启进程'", - "empty_state_approach": "illustrated (ring logo) + welcoming copy + hint", - "error_tone": "helpful not alarming — toast errors, confirm dialogs for destructive actions" - } - }, - "visual_effects": { - "overview": { - "effect_intensity": "moderate", - "performance_tier": "medium", - "fallback_strategy": "reduce to CSS (disable motion spring, keep glassmorphism)", - "primary_technology": "CSS (Tailwind) + motion/react (Framer Motion) + Canvas 2D (UsagePie charts) + HeroUI" - }, - "background_effects": { - "type": "gradient-mesh-blobs", - "description": "Three large radial gradient blobs (primary-200/40, secondary-200/40, pink-200/30) with massive blur (90-110px) positioned at corners, static but imply depth", - "technology": "CSS (absolute positioned divs with blur)", - "params": { - "color_palette": ["#FF7FAC", "#88C0D0", "#FF9EB5"], - "speed": "static", - "density": "3 blobs", - "opacity": "0.3-0.4", - "blend_mode": "normal (opacity stacking)" - } - }, - "particle_systems": { - "enabled": false - }, - "3d_elements": { - "enabled": true, - "type": "card-3d-tilt", - "description": "HoverEffectCard uses CSS 3D transforms (perspective 1000px, rotateX/rotateY up to ±5deg) with a moving gradient light blob that follows cursor", - "technology": "CSS 3D + React mouse tracking", - "params": { - "renderer": "CSS transform3d", - "lighting": "moving gradient blob (from-primary-400 to-secondary-400) follows cursor", - "camera": "perspective(1000px)", - "materials": "glassmorphism card surface", - "geometry": "flat card with perspective rotation", - "post_processing": "blur-[100px] on light blob", - "interaction_model": "mouse-move tracking within card bounds" - } - }, - "shader_effects": { - "enabled": false - }, - "scroll_effects": { - "parallax": { - "enabled": false - }, - "scroll_triggered_animations": { - "enabled": true, - "trigger_points": "route mount (Suspense), sidebar open/close, breadcrumb items", - "animation_type": "fade-up + scale (spring stagger)", - "scrub_behavior": "none (time-based)" - }, - "scroll_morphing": { - "enabled": false - } - }, - "text_effects": { - "type": "gradient-text", - "description": "HeroUI title component supports color='violet' for gradient text; login page 'Web Login' split with violet accent", - "technology": "CSS background-clip-text via HeroUI", - "params": { - "split_strategy": "by-word (Web / Login)", - "animation_per_unit": "none", - "stagger": "none", - "effect_style": "gradient fill" - } - }, - "cursor_effects": { - "enabled": true, - "type": "magnetic-light-blob", - "description": "On HoverEffectCard, a gradient radial blur blob follows cursor position within card bounds", - "technology": "CSS absolute positioned div + React mouse tracking", - "params": { - "shape": "rounded-full", - "size": "150px", - "blend_mode": "normal", - "trail": "opacity transition 300ms", - "interaction_zone": "card bounding box" - } - }, - "image_effects": { - "type": "background-overlay", - "description": "User-uploaded background image rendered full-bleed behind layout with opacity-30 object-contain", - "technology": "CSS background-image on layout root", - "params": { - "filter_pipeline": "opacity-30", - "hover_transform": "none", - "reveal_animation": "none", - "distortion_type": "none" - } - }, - "glassmorphism_neumorphism": { - "enabled": true, - "style": "glass", - "params": { - "blur_radius": "backdrop-blur-sm (4px) to backdrop-blur-xl (24px)", - "transparency": "bg-white/10-70 dark:bg-black/10-40", - "border_treatment": "border border-white/40 dark:border-white/10", - "shadow_type": "shadow-sm to shadow-xl layered", - "light_source_angle": "implied top-left via gradient blobs" - } - }, - "canvas_drawings": { - "enabled": true, - "type": "data-visualization-pie", - "description": "UsagePie component renders CPU/memory donut charts using Canvas 2D with animated sweep", - "technology": "Canvas 2D API", - "params": { - "draw_method": "arc segments with gradient fill", - "animation_loop": "on mount sweep animation", - "color_scheme": "primary/secondary/palette", - "responsiveness": "container-sized", - "interaction": "none (static display)" - } - }, - "svg_animations": { - "enabled": false - }, - "composite_notes": "The design layers: 1) Static gradient mesh background (CSS), 2) Glass cards with backdrop-blur (CSS), 3) 3D tilt cards on hover (CSS 3D + React), 4) Magnetic light blob follows cursor (React + CSS), 5) Spring animations for layout transitions (motion/react), 6) Canvas pie charts for data viz, 7) Themed scrollbars (CSS), 8) Custom background image overlay (CSS). All effects are lightweight — no WebGL, no heavy JS animation libraries beyond motion/react. HeroUI provides accessible component primitives. Dark mode inverts surfaces and adjusts blob colors. Performance is good on modern browsers; fallback is static CSS (disable motion, keep glass)." - } -} \ No newline at end of file diff --git a/tmp_benchmark/main.go b/tmp_benchmark/main.go deleted file mode 100644 index f37c234f..00000000 --- a/tmp_benchmark/main.go +++ /dev/null @@ -1,254 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "math" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "gitcode.com/JianFeeeee/HomeAgent/internal/memory" - "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" - "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" -) - -type benchDoc struct { - ID string `json:"id"` - Summary string `json:"summary"` - Content string `json:"content"` -} - -type queryCase struct { - Name string - Query string - Seeds []string -} - -type hit struct { - ID string `json:"id"` - Score float64 `json:"score"` -} - -type result struct { - Name string `json:"name"` - Query string `json:"query"` - Relevant int `json:"relevant"` - Rank int `json:"rank"` - Reciprocal float64 `json:"reciprocal_rank"` - HitAt1 bool `json:"hit_at_1"` - HitAt5 bool `json:"hit_at_5"` - LatencyMS float64 `json:"latency_ms"` - Top []hit `json:"top"` -} - -type report struct { - Documents int `json:"documents"` - Queries []queryCase `json:"queries"` - Methods map[string][]result `json:"methods"` -} - -var cases = []queryCase{ - {Name: "mail-semantic", Query: "邮件代理是否已经成功接入", Seeds: []string{"AgentMail 接入验证"}}, - {Name: "fox-cross-language", Query: "生成一张雪地红狐狸的图片", Seeds: []string{"red fox in snowy forest"}}, - {Name: "plugin-semantic", Query: "升级安装 QQ 插件包", Seeds: []string{"plugin_install"}}, - {Name: "weather-paraphrase", Query: "我所在城市的天气预报", Seeds: []string{"河南新乡"}}, - {Name: "textarea-paraphrase", Query: "聊天输入区域文字多了会不会自动增高", Seeds: []string{"输入框在内容超过一行"}}, - {Name: "devices-paraphrase", Query: "检查当前接入了哪些终端设备", Seeds: []string{"你看看现在你都有哪些设备"}}, - {Name: "memory-health", Query: "长期文档记忆功能是否健康", Seeds: []string{"文档记忆系统是否正常工作"}}, - {Name: "reload-plugins", Query: "重新加载全部扩展组件", Seeds: []string{"热重载所有插件", "plgreload"}}, - {Name: "exact-agentmail", Query: "AgentMail 接入验证", Seeds: []string{"AgentMail 接入验证"}}, - {Name: "exact-plugin", Query: "plugin_install", Seeds: []string{"plugin_install"}}, -} - -func main() { - docs, err := loadDocs("/data/homeagent/memory/documents") - if err != nil { - panic(err) - } - fmt.Fprintf(os.Stderr, "loaded %d production documents\n", len(docs)) - - rel := relevantSets(docs) - for i, c := range cases { - fmt.Fprintf(os.Stderr, "case %-20s relevant=%d query=%q\n", c.Name, len(rel[i]), c.Query) - } - - r := report{Documents: len(docs), Queries: cases, Methods: make(map[string][]result)} - - // 方案 A:纯 TF-IDF。完整训练在生产文档上,保留 IDF 高频抑制与倒排候选剪枝。 - tfidf := vector.NewTFIDFVectorizer(memory.TokenizeWords) - texts := make([]string, len(docs)) - for i, d := range docs { - texts[i] = d.Summary + "\n" + d.Content - } - tfidf.Train(texts) - tfStore := buildStore(docs, tfidf) - r.Methods["tfidf"] = runCases(tfStore, tfidf, rel) - - // 方案 B:当前生产 fastText(中英各 20/37 万词,300 维平均词向量)。 - fast := memory.NewStaticEmbedder("/data/cc.zh.top200k.vec", "/data/cc.en.top200k.vec") - fastStore := buildStore(docs, fast) - r.Methods["fasttext"] = runCases(fastStore, fast, rel) - - // 方案 C:旧通道混合。RRF 不要求两种分数处于同一标尺,避免拍脑袋设绝对权重。 - r.Methods["tfidf_fasttext_rrf"] = runHybrid(tfStore, tfidf, fastStore, fast, rel) - - out, _ := json.MarshalIndent(r, "", " ") - if err := os.WriteFile("/tmp/homeagent-old-retrieval.json", out, 0644); err != nil { - panic(err) - } - printSummary(r) -} - -func loadDocs(dir string) ([]benchDoc, error) { - ents, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - var docs []benchDoc - for _, e := range ents { - if e.IsDir() || !strings.HasPrefix(e.Name(), "doc_") || filepath.Ext(e.Name()) != ".json" { - continue - } - b, err := os.ReadFile(filepath.Join(dir, e.Name())) - if err != nil { - continue - } - var d document.Doc - if json.Unmarshal(b, &d) != nil || d.ID == "" { - continue - } - docs = append(docs, benchDoc{ID: d.ID, Summary: d.Summary, Content: d.Content}) - } - sort.Slice(docs, func(i, j int) bool { return docs[i].ID < docs[j].ID }) - return docs, nil -} - -func relevantSets(docs []benchDoc) []map[string]bool { - sets := make([]map[string]bool, len(cases)) - for i, c := range cases { - sets[i] = make(map[string]bool) - for _, d := range docs { - text := strings.ToLower(d.Summary + "\n" + d.Content) - for _, seed := range c.Seeds { - if strings.Contains(text, strings.ToLower(seed)) { - sets[i][d.ID] = true - break - } - } - } - } - return sets -} - -type textVectorizer interface { - Vectorize(text string) vector.Vector -} - -func buildStore(docs []benchDoc, v textVectorizer) *vector.Store { - s := vector.NewStore() - for _, d := range docs { - s.Insert(d.ID, d.Summary, v.Vectorize(d.Summary+"\n"+d.Content), nil) - } - return s -} - -func runCases(s *vector.Store, v textVectorizer, rel []map[string]bool) []result { - out := make([]result, 0, len(cases)) - for i, c := range cases { - start := time.Now() - hs := s.SearchScored(v.Vectorize(c.Query), s.Size()) - lat := time.Since(start) - ids := make([]hit, len(hs)) - for j, h := range hs { - ids[j] = hit{ID: h.Doc.ID, Score: h.Score} - } - out = append(out, measure(c, ids, rel[i], lat)) - } - return out -} - -func runHybrid(a *vector.Store, av textVectorizer, b *vector.Store, bv textVectorizer, rel []map[string]bool) []result { - out := make([]result, 0, len(cases)) - for i, c := range cases { - start := time.Now() - ah := a.SearchScored(av.Vectorize(c.Query), a.Size()) - bh := b.SearchScored(bv.Vectorize(c.Query), b.Size()) - scores := make(map[string]float64) - const k = 60.0 - for rank, h := range ah { - scores[h.Doc.ID] += 1 / (k + float64(rank+1)) - } - for rank, h := range bh { - scores[h.Doc.ID] += 1 / (k + float64(rank+1)) - } - ids := make([]hit, 0, len(scores)) - for id, score := range scores { - ids = append(ids, hit{ID: id, Score: score}) - } - sort.Slice(ids, func(i, j int) bool { - if ids[i].Score == ids[j].Score { - return ids[i].ID < ids[j].ID - } - return ids[i].Score > ids[j].Score - }) - out = append(out, measure(c, ids, rel[i], time.Since(start))) - } - return out -} - -func measure(c queryCase, ranked []hit, relevant map[string]bool, latency time.Duration) result { - rank := 0 - for i, h := range ranked { - if relevant[h.ID] { - rank = i + 1 - break - } - } - topN := 5 - if len(ranked) < topN { - topN = len(ranked) - } - r := result{Name: c.Name, Query: c.Query, Relevant: len(relevant), Rank: rank, LatencyMS: float64(latency.Microseconds()) / 1000, Top: append([]hit(nil), ranked[:topN]...)} - if rank > 0 { - r.Reciprocal = 1 / float64(rank) - r.HitAt1 = rank <= 1 - r.HitAt5 = rank <= 5 - } - return r -} - -func printSummary(r report) { - fmt.Printf("documents=%d queries=%d\n", r.Documents, len(r.Queries)) - names := make([]string, 0, len(r.Methods)) - for name := range r.Methods { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - rs := r.Methods[name] - var h1, h5 int - var mrr, lat float64 - for _, x := range rs { - if x.HitAt1 { - h1++ - } - if x.HitAt5 { - h5++ - } - mrr += x.Reciprocal - lat += x.LatencyMS - } - fmt.Printf("%-24s Hit@1=%d/%d Hit@5=%d/%d MRR=%.4f avg-query=%.3fms\n", name, h1, len(rs), h5, len(rs), mrr/float64(len(rs)), lat/float64(len(rs))) - for _, x := range rs { - fmt.Printf(" %-20s rank=%-4d latency=%7.3fms", x.Name, x.Rank, x.LatencyMS) - if len(x.Top) > 0 { - fmt.Printf(" top=%s score=%.4g", x.Top[0].ID, x.Top[0].Score) - } - fmt.Println() - } - } - _ = math.MaxFloat64 -} diff --git a/tmp_jinabench.py b/tmp_jinabench.py deleted file mode 100644 index 75423717..00000000 --- a/tmp_jinabench.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python3 -"""Real Jina v5-omni benchmark on HomeAgent production documents and media. - -Inputs never leave the host. Reports Recall/Hit@K, MRR, margins, and latency for: -- Jina unified dense space: text query -> text document -- Jina unified dense space: text query -> production image -""" -from __future__ import annotations - -import glob -import json -import os -import time -from pathlib import Path - -import numpy as np -import torch -from PIL import Image -from transformers import AutoModel, AutoProcessor - -MODEL = "/home/newqqagent/models/jina-v5-omni-nano" -DOC_DIR = "/home/newqqagent/memory/documents" -MEDIA_DB = "/home/newqqagent/memory/media/media.db" -BLOB_ROOT = "/home/newqqagent/memory/media/blobs" - -CASES = [ - ("mail-semantic", "邮件代理是否已经成功接入", ["AgentMail 接入验证"]), - ("fox-cross-language", "生成一张雪地红狐狸的图片", ["red fox in snowy forest"]), - ("plugin-semantic", "升级安装 QQ 插件包", ["plugin_install"]), - ("weather-paraphrase", "我所在城市的天气预报", ["河南新乡"]), - ("textarea-paraphrase", "聊天输入区域文字多了会不会自动增高", ["输入框在内容超过一行"]), - ("devices-paraphrase", "检查当前接入了哪些终端设备", ["你看看现在你都有哪些设备"]), - ("memory-health", "长期文档记忆功能是否健康", ["文档记忆系统是否正常工作"]), - ("reload-plugins", "重新加载全部扩展组件", ["热重载所有插件", "plgreload"]), - ("exact-agentmail", "AgentMail 接入验证", ["AgentMail 接入验证"]), - ("exact-plugin", "plugin_install", ["plugin_install"]), -] - -MEDIA_QUERIES = [ - ("captcha-cn", "一张带有干扰线和字符的验证码图片", "7e689211"), - ("captcha-en", "captcha with distorted letters and noise", "7e689211"), - ("news-cn", "中文新闻报道页面截图", "f6f17229"), - ("news-en", "a screenshot of a Chinese news article", "f6f17229"), - ("notes-cn", "手机深色模式备忘录截图", "ee5bdb2a"), - ("notes-en", "dark mode phone notes application screenshot", "ee5bdb2a"), -] - - -def load_docs(): - docs = [] - for p in sorted(glob.glob(DOC_DIR + "/doc_*.json")): - try: - d = json.load(open(p, encoding="utf-8")) - except Exception: - continue - if not d.get("id"): - continue - docs.append({"id": d["id"], "text": d.get("summary", "") + "\n" + d.get("content", "")}) - return docs - - -def relevant(docs, seeds): - seeds = [s.lower() for s in seeds] - return {d["id"] for d in docs if any(s in d["text"].lower() for s in seeds)} - - -def embed_text(model, proc, texts, side, batch_size=4): - result = [] - prefix = "Query: " if side == "query" else "Document: " - for start in range(0, len(texts), batch_size): - batch = [prefix + x for x in texts[start:start + batch_size]] - inp = proc(text=batch, padding=True, truncation=True, max_length=1024, return_tensors="pt") - with torch.inference_mode(): - vec = model.embed(**inp) - result.append(vec.float().cpu().numpy()) - return np.concatenate(result, axis=0) - - -def measure_text(docs, doc_vecs, model, proc): - out = [] - for name, query, seeds in CASES: - rel = relevant(docs, seeds) - t0 = time.perf_counter() - qv = embed_text(model, proc, [query], "query", 1)[0] - latency = (time.perf_counter() - t0) * 1000 - scores = doc_vecs @ qv - order = np.argsort(-scores) - rank = 0 - for pos, idx in enumerate(order, 1): - if docs[int(idx)]["id"] in rel: - rank = pos - break - best_rel = max((float(scores[i]) for i, d in enumerate(docs) if d["id"] in rel), default=float("nan")) - best_neg = max((float(scores[i]) for i, d in enumerate(docs) if d["id"] not in rel), default=float("nan")) - out.append({ - "name": name, "query": query, "relevant": len(rel), "rank": rank, - "reciprocal_rank": 1.0 / rank if rank else 0.0, - "hit_at_1": 0 < rank <= 1, "hit_at_5": 0 < rank <= 5, - "query_latency_ms": latency, - "best_relevant": best_rel, "best_negative": best_neg, - "margin": best_rel - best_neg, - "top": [{"id": docs[int(i)]["id"], "score": float(scores[i])} for i in order[:5]], - }) - return out - - -def load_images(): - rows = [ - ("7e689211", "7e/689211bf78bc2f6366405b2a1f2fe0f7ec9ccdc59f368c13175c421c8e4018"), - ("ee5bdb2a", "ee/5bdb2a5fa3927ede79384ee6ec7657428896e2690b622f2409daa4b3fa9fa3"), - ("f6f17229", "f6/f17229b3513ea1f9c660353ee4ab021bec88e2b423780e8ed4445bc9aaf2fb"), - ] - return [(name, Image.open(os.path.join(BLOB_ROOT, rel)).convert("RGB")) for name, rel in rows] - - -def embed_image(model, proc, img): - # Retrieval document side; nano's image geometry is nearly side-invariant, but use the documented prefix. - inp = proc(images=img, text="Document: ", return_tensors="pt") - with torch.inference_mode(): - return model.embed(**inp).float().cpu().numpy()[0] - - -def measure_media(images, image_vecs, model, proc): - out = [] - names = [x[0] for x in images] - for name, query, want in MEDIA_QUERIES: - t0 = time.perf_counter() - qv = embed_text(model, proc, [query], "query", 1)[0] - latency = (time.perf_counter() - t0) * 1000 - scores = image_vecs @ qv - order = np.argsort(-scores) - want_idx = names.index(want) - rank = int(np.where(order == want_idx)[0][0]) + 1 - best_neg = max(float(scores[i]) for i in range(len(names)) if i != want_idx) - out.append({ - "name": name, "query": query, "want": want, "rank": rank, - "query_latency_ms": latency, "positive": float(scores[want_idx]), - "best_negative": best_neg, "margin": float(scores[want_idx]) - best_neg, - "ranking": [{"id": names[int(i)], "score": float(scores[i])} for i in order], - }) - return out - - -def summary(rows): - return { - "queries": len(rows), - "hit_at_1": sum(r["hit_at_1"] for r in rows) / len(rows), - "hit_at_5": sum(r["hit_at_5"] for r in rows) / len(rows), - "mrr": sum(r["reciprocal_rank"] for r in rows) / len(rows), - "mean_query_latency_ms": sum(r["query_latency_ms"] for r in rows) / len(rows), - "mean_margin": sum(r["margin"] for r in rows) / len(rows), - } - - -def main(): - torch.set_num_threads(min(8, os.cpu_count() or 1)) - print("loading Jina v5-omni-nano (vision + text)...", flush=True) - t0 = time.perf_counter() - model = AutoModel.from_pretrained( - MODEL, trust_remote_code=True, local_files_only=True, - default_task="retrieval", modality="vision", dtype=torch.float32, - ).eval() - proc = AutoProcessor.from_pretrained(MODEL, trust_remote_code=True, local_files_only=True) - load_s = time.perf_counter() - t0 - docs = load_docs() - print(f"loaded model in {load_s:.2f}s, embedding {len(docs)} docs", flush=True) - t0 = time.perf_counter() - doc_vecs = embed_text(model, proc, [d["text"] for d in docs], "document", batch_size=4) - doc_embed_s = time.perf_counter() - t0 - text_rows = measure_text(docs, doc_vecs, model, proc) - - images = load_images() - t0 = time.perf_counter() - image_vecs = np.stack([embed_image(model, proc, img) for _, img in images]) - image_embed_s = time.perf_counter() - t0 - media_rows = measure_media(images, image_vecs, model, proc) - - report = { - "model": "jina-v5-omni-nano", "dimension": int(doc_vecs.shape[1]), - "model_load_seconds": load_s, "documents": len(docs), - "document_embedding_seconds": doc_embed_s, - "document_embedding_ms_per_doc": doc_embed_s * 1000 / len(docs), - "text_retrieval": text_rows, "text_summary": summary(text_rows), - "images": len(images), "image_embedding_seconds": image_embed_s, - "image_embedding_ms_per_image": image_embed_s * 1000 / len(images), - "media_retrieval": media_rows, - "media_summary": { - "queries": len(media_rows), - "hit_at_1": sum(r["rank"] == 1 for r in media_rows) / len(media_rows), - "mrr": sum(1.0 / r["rank"] for r in media_rows) / len(media_rows), - "mean_margin": sum(r["margin"] for r in media_rows) / len(media_rows), - "mean_query_latency_ms": sum(r["query_latency_ms"] for r in media_rows) / len(media_rows), - }, - } - Path("/tmp/homeagent-jina-benchmark.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({k: report[k] for k in ["model", "dimension", "model_load_seconds", "documents", "document_embedding_seconds", "document_embedding_ms_per_doc", "text_summary", "images", "image_embedding_seconds", "image_embedding_ms_per_image", "media_summary"]}, ensure_ascii=False, indent=2)) - for r in text_rows: - print(f"TEXT {r['name']:20s} rank={r['rank']:3d} margin={r['margin']:+.4f} latency={r['query_latency_ms']:.1f}ms") - for r in media_rows: - print(f"MEDIA {r['name']:20s} rank={r['rank']} pos={r['positive']:+.4f} neg={r['best_negative']:+.4f} margin={r['margin']:+.4f}") - - -if __name__ == "__main__": - main()