Files
EzVibeR/docs/impl/llm-chat-implementation.md
2026-06-12 17:24:21 +08:00

383 lines
17 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+ 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 + 本地模型文件)
- 情绪引擎EmotionEngine5 个状态)
- 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.cpp2023年中**不支持 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 APIsystem 字段提到顶层)
- 全部用 `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→90sremind_water、60min→180sremind_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` 各自 overridekey 非空即 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/v1Mymodel / EzVibe 模型)
✅ 401 错误友好提示API key 无效时显示在气泡里)
✅ 思考气泡可折叠DeepSeek-style 输出)
✅ 聊天面板收起/展开 + 模型自动重居中
✅ 双击不触发全屏
✅ 90s/180s 自动 reminder已生产化建议改回 45min/60min
✅ 手动 🔔 按钮触发 reminder
❌ Anthropic 真实 key 测试(未做)
❌ Streaming未做
❌ 多个不同 provider 切换(未做)
---
**总结**:核心对话能力跑通,从「不能对话」到「能对话」+「能健康提醒」+「思考可视化」+「UI 灵活」,用户反馈驱动迭代 6 轮完成。后续如要继续,主要方向是 streaming、GPU、Anthropic 验证。