Files
EzVibeR/docs/impl/2026-06-12-feature-implementation.md
2026-06-14 16:29:59 +08:00

208 lines
8.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EzVibeR+ 功能增强实现记录
> 日期: 2026-06-12
> 作者: CodeBuddy
> 分支: main
---
## 一、实现功能总览
本次共实现以下功能:
| # | 功能 | 状态 |
|---|------|------|
| 1 | 配置界面展示 Live2D 动作/表情标签 | ✅ 完成 |
| 2 | LLM 返回动作标签驱动角色动画 | ✅ 完成 |
| 3 | Idle 时根据情绪引擎自动调节表情 | ✅ 完成 |
| 4 | 点击角色身体截图 + 发送给 AI 分析 | ✅ 完成 |
---
## 二、架构决策与关键经验
### 2.1 ChatPanel 点击问题 (最重要的经验)
**现象**: ChatPanel 在 Tauri 透明窗口中无法响应鼠标点击(可以通过键盘 Enter 发送)。
**根因分析**: PIXI.js 的 `resizeTo: window` 使 canvas 覆盖整个窗口。在 Tauri 透明窗口 + Linux 环境下,这个全窗口 canvas 的 WebGL 渲染表面会拦截所有鼠标事件,即使 ChatPanel 在 DOM 中位于上层、CSS 设置了 `pointer-events: auto` 也无效。
**尝试过的方案** (全部失败):
1. Canvas 设 `pointer-events: none` — 无效WebGL 层在 compositor 层面拦截
2. 调整 CSS `z-index` — 无效WebGL 渲染层级高于 DOM 层级
3. 使用 `window.addEventListener` 替代 canvas 点击 — 仍无法让 ChatPanel 接收点击
4. 关闭窗口 `transparent: true` — 仍然无效
5. 调整 flex 布局、`width: 100%` 等 — 无效
**最终方案**: 将 ChatPanel 放到**独立的 Tauri 窗口**中(`transparent: false`, `decorations: false`与主窗口Live2D 模型窗口)贴合在一起,通过 Tauri 事件系统通信。
**双窗口架构**:
```
┌──────────────┐┌──────────────┐
│ Live2D 模型 ││ ChatPanel │
│ (透明, PIXI) ││ (正常窗口) │
│ pointer-events││ 点击完全正常 │
│ 通过 hitbox ││ │
└──────────────┘└──────────────┘
主窗口 (main) 聊天窗口 (chat)
←── 移动/缩放时自动跟随 ──→
```
不过用户最终选择了 ChatPanel 在同一个窗口中渲染的方案(使用 `flex-direction: row-reverse` + `z-index` 分层),该方案在用户的桌面环境上成功。
**经验总结**:
- Tauri + PIXI 透明窗口在 Linux 上存在 compositor 层级的点击拦截问题
- 如果单窗口方案失败,独立窗口是可靠的后备方案
- `flex-direction: row-reverse` 可以改变 DOM 渲染顺序,配合 `z-index` 改变层叠关系
---
### 2.2 截图方案选择
在 Linux 上实现截图,最初考虑使用 `xcap` crate但需要安装大量系统依赖pipewire, xcb 等)。改为使用系统已有的 ImageMagick `convert` 命令:
```rust
// 最终采用的截图命令
Command::new("convert")
.arg("x:root") // X11 root window
.arg(path) // 输出 PNG
```
备选工具链: `import -display :0 -window root` (ImageMagick)、`scrot``maim`
---
## 三、文件变更清单
### Rust 后端
| 文件 | 变更 |
|------|------|
| `src-tauri/Cargo.toml` | 添加 `image` (png/jpeg 编码)、`base64` 依赖 |
| `src-tauri/tauri.conf.json` | ChatPanel 独立窗口配置(后续回退) |
| `src-tauri/src/main.rs` | `AppState` 增加 `current_model_url`;注册新命令 `set_model_url``get_model_capabilities``capture_screenshot`heartbeat 增加 `expression_variations` |
| `src-tauri/src/app/commands.rs` | 新增 `set_model_url``get_model_capabilities``capture_screenshot` 命令;`chat` 命令增加 `image_data` 参数;发送 `ezvibe:motion` 事件 |
| `src-tauri/src/modules/mod.rs` | 注册 `screenshot` 模块;导出 `ScreenshotData``ImageAttachment` |
| `src-tauri/src/modules/brain.rs` | `ChatMessage` 增加 `image` 字段;新增 `ImageAttachment` 结构体;`think` 签名增加 `image` 参数;新增 `think_with_image` 多模态流程;新增 `build_screenshot_prompt` 截专用提示词;新增 `parse_motion_tag``strip_tags` 方法;`BrainResponse` 增加 `motion_tag` 字段 |
| `src-tauri/src/modules/openai.rs` | `OaiMessage.content` 改为 `serde_json::Value` 支持多模态(文本+图片) |
| `src-tauri/src/modules/anthropic.rs` | `AntMessage.content` 改为 `serde_json::Value` 支持多模态 |
| `src-tauri/src/modules/emotion.rs` | `EmotionState` 新增 `subtle_variations()` 方法(每个情绪提供微表情关键词) |
| `src-tauri/src/modules/screenshot.rs` | **新文件** — 全屏截图模块 (`convert x:root` → PNG/JPEG base64) |
### Vue 前端
| 文件 | 变更 |
|------|------|
| `src/components/Config.vue` | 新增「🎭 当前 Live2D 模型能力」区域(动作组列表、表情列表、情绪映射表) |
| `src/live2d/index.vue` | 截图功能集成capture + emit 事件);模型点击用 `model-hitbox` div 代理;`flex-direction: row-reverse` + `z-index` 解决 ChatPanel 点击问题 |
| `src/live2d/components/ChatPanel.vue` | 暴露 `sendImage` 方法(用于截图发送) |
| `src/live2d/components/MessageBubble.vue` | 新增 `screenshot` 角色消息类型(预览图 + 输入框 + 发送按钮) |
| `src/live2d/hooks/useChat.ts` | 新增 `sendImage`/`sendImageInternal` 方法;监听 `ezvibe:screenshot-captured``ezvibe:screenshot-send` 事件 |
| `chat.html` | **新文件** — 独立聊天窗口入口(后期回退到单窗口方案) |
| `src/chat/App.ts` | **新文件** — 独立窗口 ChatPanel 启动脚本(后期删除) |
| `vite.config.ts` | 添加 `chat` 入口点(后期回退) |
---
## 四、功能详情
### 4.1 LLM 动作标签
**System Prompt 增加的指令**:
```
【重要】每次回复末尾必须附带一个动作标签,格式为 [MOTION: 动作名]。
可用动作: idle, happy, focused, angry, sleepy, wink, surprised, sad, nod
示例:
用户: "今天好累" → 你: "辛苦啦 [MOTION: sleepy]"
用户: "通过了考试!" → 你: "太棒了! [MOTION: happy]"
```
**后端处理流程**:
1. LLM 返回含 `[MOTION: xxx]` 的文本
2. `parse_motion_tag()` 提取标签
3. `strip_tags()` 剥离标签,返回干净文本
4. `ezvibe:motion` 事件驱动前端播放 Live2D 动作/表情
### 4.2 Idle 表情自动调节
- `EmotionState::subtle_variations()` 为每种情绪提供 4 个微表情关键词
- heartbeat (60s) 携带 `expression_variations` 到前端
- 前端 idle 定时器 (30s) 检查是否超过 45s 无交互
- 无交互时从微表情列表中轮换
### 4.3 截图 + AI 分析
**流程**:
1. 点击角色身体 → hitbox 接收点击
2. `captureAndHandleScreenshot()` → invoke `capture_screenshot` (Rust)
3. Rust 执行 `convert x:root /tmp/xxx.png` 截图
4. 返回 PNG + JPEG (预览) base64
5. 通过 `ezvibe:screenshot-captured` 事件发送到 ChatPanel
6. ChatPanel 渲染截图消息气泡(缩略图 + 输入框 + 发送按钮)
7. 用户输入 → `ezvibe:screenshot-send` 事件 → `useChat.sendImageInternal`
8. invoke `chat({message, imageData})` → 多模态 LLM 请求
9. LLM 返回 → 显示在聊天流中
**多模态 LLM 协议适配**:
- OpenAI: `content: [{type: "text", text: "..."}, {type: "image_url", image_url: {url: "data:image/png;base64,..."}}]`
- Anthropic: `content: [{type: "text", text: "..."}, {type: "image", source: {type: "base64", media_type: "image/png", data: "..."}}]`
### 4.4 配置界面模型能力展示
Config.vue 新增区域显示:
- **情绪映射表**: Idle/Happy/Focused/Annoyed/Sleepy → 匹配到的模型动作 + 表情
- **可用动作组**: 从 model3.json 的 Motions 解析 (如 `zhaiyan`, `zhaoxiang`)
- **可用表情**: 从 FileReferences.Expressions 解析 (如 `捂脸`, `星星`, `比耶`)
后端命令 `get_model_capabilities` 通过 reqwest 从本地 Axum HTTP 服务器读取 model3.json。
---
## 五、ChatPanel 点击问题最终方案
**关键 CSS**:
```css
.live2d-shell {
display: flex;
flex-direction: row-reverse; /* ChatPanel 先渲染 */
}
.chat-pane {
position: relative;
z-index: 999; /* 最高层级 */
pointer-events: auto !important;
}
.live2d-view {
z-index: 0; /* 模型在下层 */
}
.model-hitbox {
/* 透明 div 代理模型点击 */
position: absolute;
z-index: 1;
}
```
**Canvas 处理**: `canvas.style.pointerEvents = "none"` — 永不拦截 DOM 事件。
**备选方案**: 如果单窗口方案在特定系统上不工作,可使用双窗口架构(`tauri.conf.json` 定义 `chat` 窗口 + `main.rs` setup 代码定位窗口 + 前端 `onMoved`/`onResized` 同步位置)。
---
## 六、编译与运行
```bash
# 前端
npm install
npm run build
# 后端 (release)
cd src-tauri && cargo build --release
# 运行
DISPLAY=:0 ./src-tauri/target/release/live2d
# 开发模式
npm run tauri:dev
```