208 lines
8.6 KiB
Markdown
208 lines
8.6 KiB
Markdown
# 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
|
||
```
|