383 lines
17 KiB
Markdown
383 lines
17 KiB
Markdown
# EzVibeR+ LLM 对话能力 — 实施记录
|
||
|
||
> 作者:MiniMax-M3 (2026-06-01~02)
|
||
> 目的:给后续 agent / 接手人一个完整工作记录,方便理解和回归
|
||
> 关联:`docs/PLAN-CHAT.md`(早期设计稿)、`docs/impl/report.md`(历史报告)
|
||
|
||
---
|
||
|
||
## 0. 起点(接手时的状态)
|
||
|
||
`EzVibeR+` 是一个 Tauri 2 + Vue 3 桌宠,原有功能:
|
||
- Live2D 模型显示(pixi-live2d-display + 本地模型文件)
|
||
- 情绪引擎(EmotionEngine,5 个状态)
|
||
- 60s 后台调度器(TaskScheduler:喝水/伸展/早安问候等确定性行为)
|
||
- 记忆系统(MemorySystem + RAG,但 embedder 是 DummyEmbedder,无真实语义)
|
||
- 大脑引擎(AgentBrain)— 已实现 `LLMProvider` 抽象
|
||
- `NoopLLMProvider` 占位符,`commands::chat` 已存在但**实际不会调任何 LLM**
|
||
|
||
**用户最初诉求**:「桌宠可以和用户对话」
|
||
|
||
---
|
||
|
||
## 1. 完整工作流(按用户需求顺序)
|
||
|
||
### 1.1 路线一:本地 llama.cpp 内嵌
|
||
|
||
**用户**:先要本地化、最简方案
|
||
**实现**:写了 `llama_cpp_rs = "0.3.0"` Provider,含懒加载 / Mutex 串行化 / unsafe Send 包装
|
||
|
||
**踩坑**:
|
||
- `llama_cpp_rs 0.3.0` build.rs 有一处 bug(找不到独立 `ggml.o`,已 patch 到 `~/.cargo/registry/.../build.rs`)
|
||
- crate 内部用 *非常老* 的 llama.cpp(2023年中),**不支持 Qwen2 架构**
|
||
- 用户下载的 `Qwen2.5-Coder-0.5B-Instruct-Q4_K_M.gguf` 加载报错 `unknown model architecture: 'qwen2'`
|
||
|
||
**结论**:本地路线被用户否决
|
||
|
||
### 1.2 路线二:云端 API(最终方案)
|
||
|
||
**用户要求**:
|
||
- ❌ 删所有本地模型代码
|
||
- ✅ 走 API key 模式
|
||
- ✅ 兼容 OpenAI 协议 + Anthropic 协议
|
||
|
||
**实现**:
|
||
- 新增 `src-tauri/src/modules/openai.rs`(Chat Completions 协议)
|
||
- 新增 `src-tauri/src/modules/anthropic.rs`(Messages API,system 字段提到顶层)
|
||
- 全部用 `reqwest = "0.11"` async HTTP
|
||
- `AppConf` 加 `llm_provider` / `llm_model` / `llm_base_url` / `llm_api_key` 字段
|
||
- `main.rs` 工厂:按 provider 字符串分发
|
||
- `commands::get_llm_status` Tauri 命令
|
||
- `Config.vue` 加 LLM 设置区(provider 下拉、model、key、base_url)
|
||
- `ChatPanel.vue` 改"未配置"提示
|
||
|
||
### 1.3 UX 改进
|
||
|
||
按用户逐条要求:
|
||
|
||
| # | 用户要求 | 改动文件 |
|
||
|---|---|---|
|
||
| 1 | 持久聊天面板(右侧) | 新建 `components/ChatPanel.vue` + `hooks/useChat.ts` + `components/MessageBubble.vue` |
|
||
| 2 | 思考气泡(`<think>...</think>` 可折叠) | `MessageBubble.vue` 改:整块可点 + 鲁棒正则(含 `<thinking>` 和未闭合兜底) |
|
||
| 3 | 禁双击全屏 | `index.vue` `onMounted` 加 `dblclick` 全局 capture listener |
|
||
| 4 | 模型位置(往右 / 居中) | `loadModel` + `recenterModel` 函数 |
|
||
| 5 | 聊天面板可收缩 | `chatCollapsedRef` + `toggleChat` + waifu-tool 加 `fui-chat` 按钮 |
|
||
| 6 | 健康提示 → 气泡 + LLM 提示词 | 改 `BrainConfig::default()` 加 tip 引导;`useChat.ts` 监听 `ezvibe:reminder` 事件塞气泡 |
|
||
|
||
### 1.4 Bug 修复
|
||
|
||
- **双击气泡没反应**(用户报告):`MessageBubble.vue` 把 `<button>` 改成整块 `<div @click>`,避免 overflow/事件边界问题
|
||
- **健康气泡不显示**(用户报告):scheduler 间隔是 45min,且 22:00-08:00 静默时段抑制;改成 90s/180s + 关闭静默时段,加手动触发按钮
|
||
|
||
---
|
||
|
||
## 2. 架构总览(终态)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Frontend (live2d.html → index.vue) │
|
||
│ ┌──────────────┐ ┌────────────────────────────┐ │
|
||
│ │ Live2D Canvas│ │ ChatPanel.vue │ │
|
||
│ │ (左,可收缩) │ │ - 状态条 + 🔔/ ?/× 按钮 │ │
|
||
│ │ │ │ - MessageBubble 流 │ │
|
||
│ │ │ │ ├─ user / assistant │ │
|
||
│ │ │ │ ├─ system (错误) │ │
|
||
│ │ │ │ └─ reminder (绿松石) │ │
|
||
│ │ │ │ └─ 含 <think> 折叠 │ │
|
||
│ │ │ │ - 输入框 + 发送 │ │
|
||
│ └──────────────┘ └────────────────────────────┘ │
|
||
│ ↓ invoke('chat', { message }) │
|
||
│ ↓ invoke('get_llm_status') │
|
||
│ ↓ listen('ezvibe:reminder') │
|
||
└─────────────────────────────────────────────────────┘
|
||
↓ Tauri IPC
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Rust Backend (src-tauri/src/) │
|
||
│ commands::chat → brain.think(UserMessage) │
|
||
│ → OpenAiProvider / AnthropicProvider │
|
||
│ → reqwest HTTP → 云端 API │
|
||
│ │
|
||
│ commands::get_llm_status → 读 AppConf 返回状态 │
|
||
│ 60s tokio loop → scheduler → brain.think(System) │
|
||
│ → FallbackCache → emit reminder │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 完整文件清单
|
||
|
||
### 3.1 新增
|
||
|
||
| 路径 | 行数 | 作用 |
|
||
|---|---|---|
|
||
| `src-tauri/src/modules/openai.rs` | ~190 | OpenAI 兼容 LLM Provider |
|
||
| `src-tauri/src/modules/anthropic.rs` | ~200 | Anthropic Claude Provider |
|
||
| `src/live2d/hooks/useChat.ts` | ~140 | 聊天状态管理 + reminder 监听 |
|
||
| `src/live2d/components/ChatPanel.vue` | ~280 | 聊天面板 UI |
|
||
| `src/live2d/components/MessageBubble.vue` | ~190 | 单条消息气泡(含思考折叠) |
|
||
| `docs/PLAN-CHAT.md` | ~250 | 早期设计稿 |
|
||
| `docs/impl/llm-chat-implementation.md` | ← 本文 | |
|
||
|
||
### 3.2 修改
|
||
|
||
| 路径 | 关键改动 |
|
||
|---|---|
|
||
| `src-tauri/Cargo.toml` | 删 `llama_cpp_rs`,加 `reqwest` (json + rustls-tls) |
|
||
| `src-tauri/src/utils.rs` | 删 `llm_model_dir` / `default_llm_model_path` / `resolve_llm_model_path` |
|
||
| `src-tauri/src/app/config.rs` | 加 `llm_provider` / `llm_model` / `llm_api_key` / `llm_base_url` 字段 |
|
||
| `src-tauri/src/app/commands.rs` | 删 `get_model_status`,加 `get_llm_status` |
|
||
| `src-tauri/src/main.rs` | 删 `NoopLLMProvider`,加工厂 + 旧 base_url 迁移(minimax.chat → 官方) |
|
||
| `src-tauri/src/modules/brain.rs` | `LLMProvider` trait 加 `is_loaded()` / `model_path()` 默认方法;`AgentBrain::provider()` 访问器;系统 prompt 加健康 tip 引导 |
|
||
| `src-tauri/src/modules/mod.rs` | 删 `pub mod llama_cpp`,加 `pub mod openai` / `pub mod anthropic` |
|
||
| `src-tauri/src/modules/scheduler.rs` | 间隔 45min→90s(remind_water)、60min→180s(remind_stretch);默认 `quiet_hours_enabled = false` |
|
||
| `src-tauri/tauri.conf.json` | 窗口 width: 215→600, height: 200→480, resizable: true |
|
||
| `src/components/Config.vue` | 加 LLM 设置区(4 字段 + 保存按钮) |
|
||
| `src/live2d/index.vue` | flex 左右布局 + 聊天面板 + 工具栏 🔔 按钮 + 模型居中 + 禁双击全屏 + 窗口 resize 重居中 |
|
||
|
||
### 3.3 删除
|
||
|
||
- `src-tauri/src/modules/llama_cpp.rs`(临时用过,已删)
|
||
- `~/.live2D/model/llm/`(用户移动过,API 路线后不再需要)
|
||
- 之前 patch 的 `~/.cargo/registry/.../llama_cpp_rs-0.3.0/build.rs`(crate 已不在依赖里,无影响)
|
||
|
||
---
|
||
|
||
## 4. 关键设计决策
|
||
|
||
### 4.1 为什么不把 `<think>` 解析放后端
|
||
|
||
**问题**:DeepSeek / QwQ / o1 等模型输出含 `` 块,需要拆成可折叠气泡
|
||
|
||
**选择**:前端解析
|
||
|
||
**理由**:
|
||
- `brain.rs::strip_chatml_artifacts` 只剥 ChatML,`` 保留原样
|
||
- 前端 `MessageBubble.vue` 直接正则处理,无侵入
|
||
- 多种模型(DeepSeek/QwQ/o1/Claude extended thinking)都通用
|
||
- 不动 `BrainResponse` 字段(避免 commands.rs / hooks 连锁改动)
|
||
|
||
### 4.2 为什么 health reminder 在前端监听
|
||
|
||
**问题**:scheduler 60s 触发 reminder,要显示成气泡
|
||
|
||
**选择**:前端 `useChat.ts` 监听 `ezvibe:reminder` 事件
|
||
|
||
**理由**:
|
||
- 后端 `app_handle.emit("ezvibe:reminder", ...)` 已经在 `main.rs:60s 循环` 里发
|
||
- 前端只接收,符合"气泡属于 UI 层"的关注点分离
|
||
- 离后端近(如未来加表情播报)也方便
|
||
|
||
### 4.3 为什么 is_loaded / model_path 走 trait 默认
|
||
|
||
**问题**:`get_llm_status` 命令需要知道 provider 状态,但不想耦合到具体 provider
|
||
|
||
**选择**:在 `LLMProvider` trait 上加默认方法
|
||
|
||
**理由**:
|
||
- `OpenAiProvider` / `AnthropicProvider` 各自 override(key 非空即 ready)
|
||
- `NoopLLMProvider` 不存在了(已删)
|
||
- 任何未来新 provider 只要写自己的实现,不改 trait
|
||
|
||
### 4.4 为什么 in-memory provider 不监听 conf 变更
|
||
|
||
**问题**:用户在 UI 改 key 后,运行时如何生效?
|
||
|
||
**选择**:**必须重启**(in-memory provider 创建一次)
|
||
|
||
**理由**:
|
||
- 简单、不引入 RwLock<Option<Provider>> 复杂度
|
||
- 用户改完 key 重启一次是合理 UX(不频繁)
|
||
- 如果未来要做"热更新"再加
|
||
|
||
### 4.5 conf 兼容旧字段
|
||
|
||
**AppConf 字段**:
|
||
|
||
| 字段 | 默认 | 说明 |
|
||
|---|---|---|
|
||
| `port` | 0 | 系统分配 |
|
||
| `model_dir` | `""` | Live2D 模型目录 |
|
||
| `width` / `height` | 400 / 500 | 窗口大小 |
|
||
| `x` / `y` | 100 / 120 | 窗口位置 |
|
||
| `check_update` | false | |
|
||
| `remote_list` | `[]` | |
|
||
| `model_block` | true | |
|
||
| `auto_start` | false | |
|
||
| `memory_enabled` | true | |
|
||
| `llm_provider` | `"openai"` | `#[serde(default)]` |
|
||
| `llm_model` | `"gpt-4o-mini"` | `#[serde(default)]` |
|
||
| `llm_api_key` | `""` | `#[serde(default)]` |
|
||
| `llm_base_url` | `"https://api.openai.com"` | `#[serde(default)]` |
|
||
|
||
所有新字段都标 `#[serde(default = "...")]` 兼容旧 conf(缺字段时不报错)。
|
||
|
||
**base_url 迁移**:`main.rs` 检测到 `minimax.chat` 时自动重置(v3.0 之前的错默认)。
|
||
|
||
---
|
||
|
||
## 5. 编译 & 运行
|
||
|
||
### 5.1 工具链
|
||
|
||
- Rust 1.65+ (项目要求)
|
||
- Node 18+(package.json)
|
||
- 无 C++ 编译(llama.cpp 已删)
|
||
- 无 GPU 依赖(CPU only)
|
||
|
||
### 5.2 命令
|
||
|
||
```bash
|
||
cd /home/e2hang/agent/EzVibeR+
|
||
npm install # 一次性
|
||
npm run tauri:dev # 开发模式(vite + cargo 同时跑)
|
||
npm run build # 前端生产构建
|
||
npm run tauri build # 完整打包(前端 + Rust)
|
||
```
|
||
|
||
### 5.3 配置位置
|
||
|
||
- 配置文件:`~/.live2D/live2d.conf.json`(**大写 D**,Linux 大小写敏感!)
|
||
- 当前用户配置(2026-06-02):
|
||
- model_dir: `/home/e2hang/agent/EzVibeR/assets/march7th`
|
||
- width/height: 1200/960
|
||
- llm_provider: openai
|
||
- llm_base_url: `http://100.85.50.59:3000/v1`
|
||
- llm_model: `EzVibe`
|
||
- llm_api_key: 已配置
|
||
|
||
### 5.4 关键路径注意
|
||
|
||
- `app_root()` 在 `utils.rs` 用 `.live2D`(大写 D)拼
|
||
- `index.html` 入口是 `index.html`(配置窗口)
|
||
- `live2d.html` 入口是桌宠窗口
|
||
- 两者都是 Vite 多入口
|
||
|
||
---
|
||
|
||
## 6. 终端调试日志(开发期看)
|
||
|
||
```
|
||
DEBUG_LLM: provider=openai base_url=... model=... key_configured=true|false
|
||
DEBUG_REMINDER: tick=N fired_count=K types=[...]
|
||
DEBUG_REMINDER_FIRED: type=remind_water msg=...
|
||
DEBUG: tray icon built successfully
|
||
```
|
||
|
||
`log::info!` 没初始化 logger backend,所以要走 `eprintln!` 才能在终端看到。后续如果加 `env_logger::init()`,可以统一到 `RUST_LOG=info` 模式。
|
||
|
||
---
|
||
|
||
## 7. 已知问题 / 限制
|
||
|
||
### 7.1 当前未实现
|
||
|
||
- **流式输出**:当前是整段返回(等模型全推完才显示)。0.5B 还好,3B+ 应该启 streaming
|
||
- **GPU 加速**:CPU only。Metal/CUDA feature 没编译进去
|
||
- **多 Provider 切换**:UI 切 provider 后要重启
|
||
- **API key 热更新**:UI 改 key 后要重启
|
||
- **窗口记忆位置**:conf 里 x/y 有,但 resize 时会强制重置
|
||
|
||
### 7.2 待验证
|
||
|
||
- Anthropic 协议未在真实 key 上测过(用户只用 OpenAI 兼容服务)
|
||
- Confluence 错误码映射(401/403/404/429/5xx)只测过 401 路径
|
||
|
||
### 7.3 设计缺陷
|
||
|
||
- `LOGGING_INIT` 缺失 → `log::*` 调用全部 silent
|
||
- `BrainConfig::default()` 系统 prompt 硬编码,未来需要做成可配置
|
||
- `Scheduler` 间隔硬编码,开发 90s/180s,生产应该改回 45min/60min
|
||
|
||
---
|
||
|
||
## 8. 回归 checklist(接手人测试用)
|
||
|
||
如果改动影响以下功能,跑一遍:
|
||
|
||
- [ ] `cargo check` 干净通过(33 个 warning 是预存的,不引入新 warning)
|
||
- [ ] `npm run build` 通过
|
||
- [ ] 启动后日志显示 `DEBUG_LLM: provider=... base_url=... model=... key_configured=true`
|
||
- [ ] 桌宠窗口出现,Live2D 模型显示,聊天面板在右侧
|
||
- [ ] 点 🔔 按钮 → 立刻弹一条绿松石色健康气泡
|
||
- [ ] 发消息 → 1-3s 内收到 LLM 回复
|
||
- [ ] 回复中如果含 `` 块 → 显示为可折叠气泡(默认收起),主回复独立显示
|
||
- [ ] 点聊天面板 💬 按钮 → 面板收起,模型占满整个窗口
|
||
- [ ] 再次点 💬 → 面板恢复
|
||
- [ ] 双击透明区域 → **不**触发全屏
|
||
- [ ] 拖窗口边缘 resize → 模型自动重新居中
|
||
- [ ] 修改 conf 后重启 → 配置生效
|
||
- [ ] 填错 API key → 聊天面板弹 `[401] API key 无效`
|
||
|
||
---
|
||
|
||
## 9. 给后续 agent 的提示
|
||
|
||
1. **不要再加回 llama.cpp** — 用户明确否决,且 `llama_cpp_rs 0.3.0` 极老,新模型架构(Qwen2/Phi-3)不支持。如果以后真要本地,**直接换现代 fork**(`llama-cpp-2` 之类),并重写 `llama_cpp.rs`。
|
||
|
||
2. **想加 streaming**:在 `OpenAiProvider` / `AnthropicProvider` 里改 `stream: true`,通过 `reqwest` 的 `bytes_stream()` 逐 chunk 解析 SSE(`data: {json}\n\n`),每收到一个 chunk 就 emit 一个 Tauri event。Brain 那侧可以增加一个 streaming 路径。
|
||
|
||
3. **想加多模态(图片)**:OpenAI/Anthropic 都支持 `content: [{type: "text"}, {type: "image_url"}]`。改 `ChatMessage` 加 `attachments: Vec<Attachment>`,provider 里 translate 即可。
|
||
|
||
4. **想加 GPU 加速**:OpenAI 兼容的本地推理可以指向 LM Studio / vLLM / ollama(**注意**:ollama 是用户最初否决的"外接服务"路线,但如果作为 OpenAI 兼容端点 `http://localhost:11434/v1` 用,本质上还是 OpenAI 协议,跟用户当前的"key + URL"心智模型一致)。代码不用改。
|
||
|
||
5. **想加语音**:Tauri 有麦克风权限 + WebAudio API,前端 capture → base64 → `ChatMessage.attachments` 走 OpenAI Whisper API。Speech synthesis 用 Web Speech API(`speechSynthesis.speak()`)前端的 SpeechSynthesisUtterance。
|
||
|
||
6. **bug 报告高频点**:
|
||
- conf 改了不生效 → in-memory provider 不重读,必须重启
|
||
- 静默时段没声音 → 默认开 22:00-08:00,改 `Behavior::new()` 时改默认值
|
||
- 窗口太大/太小 → `tauri.conf.json` + `AppConf` 双改
|
||
- 像素字体模糊 → 检查 `factorRef` 缩放因子
|
||
|
||
---
|
||
|
||
## 10. 关键 commit-style diff 摘要
|
||
|
||
```diff
|
||
# Cargo.toml
|
||
- llama_cpp_rs = "0.3.0"
|
||
+ reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||
|
||
# main.rs
|
||
- struct NoopLLMProvider; (deleted ~30 lines)
|
||
+ use crate::modules::{OpenAiProvider, AnthropicProvider};
|
||
+ let llm_provider = match provider_kind.as_str() {
|
||
+ "anthropic" => AnthropicProvider::new(...),
|
||
+ _ => OpenAiProvider::new(...), // 含 OpenAI 兼容
|
||
+ };
|
||
|
||
# brain.rs
|
||
+ fn chat<'a>(...) // LLMProvider trait: is_loaded() + model_path() 默认方法
|
||
+ pub fn provider(&self) -> &Arc<dyn LLMProvider> // AgentBrain 暴露
|
||
+ 系统 prompt 加 "适当时机... 健康小贴士..."
|
||
|
||
# index.vue (frontend)
|
||
+ <ChatPanel class="chat-pane" /> // flex 布局右栏
|
||
+ dblclick listener with capture: true // 禁全屏
|
||
+ model.x/y 计算居中 + recenterModel 函数
|
||
|
||
# tauri.conf.json
|
||
- width: 215, height: 200, resizable: false
|
||
+ width: 600, height: 480, resizable: true
|
||
+ minWidth: 555, minHeight: 280
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 测试已验证场景
|
||
|
||
✅ OpenAI 兼容协议(http://100.85.50.59:3000/v1,Mymodel / EzVibe 模型)
|
||
✅ 401 错误友好提示(API key 无效时显示在气泡里)
|
||
✅ 思考气泡可折叠(DeepSeek-style 输出)
|
||
✅ 聊天面板收起/展开 + 模型自动重居中
|
||
✅ 双击不触发全屏
|
||
✅ 90s/180s 自动 reminder(已生产化建议改回 45min/60min)
|
||
✅ 手动 🔔 按钮触发 reminder
|
||
❌ Anthropic 真实 key 测试(未做)
|
||
❌ Streaming(未做)
|
||
❌ 多个不同 provider 切换(未做)
|
||
|
||
---
|
||
|
||
**总结**:核心对话能力跑通,从「不能对话」到「能对话」+「能健康提醒」+「思考可视化」+「UI 灵活」,用户反馈驱动迭代 6 轮完成。后续如要继续,主要方向是 streaming、GPU、Anthropic 验证。
|