17 KiB
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.0build.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_statusTauri 命令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> 复杂度
- 用户改完 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 命令
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: 已配置
- model_dir:
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::*调用全部 silentBrainConfig::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 的提示
-
不要再加回 llama.cpp — 用户明确否决,且
llama_cpp_rs 0.3.0极老,新模型架构(Qwen2/Phi-3)不支持。如果以后真要本地,直接换现代 fork(llama-cpp-2之类),并重写llama_cpp.rs。 -
想加 streaming:在
OpenAiProvider/AnthropicProvider里改stream: true,通过reqwest的bytes_stream()逐 chunk 解析 SSE(data: {json}\n\n),每收到一个 chunk 就 emit 一个 Tauri event。Brain 那侧可以增加一个 streaming 路径。 -
想加多模态(图片):OpenAI/Anthropic 都支持
content: [{type: "text"}, {type: "image_url"}]。改ChatMessage加attachments: Vec<Attachment>,provider 里 translate 即可。 -
想加 GPU 加速:OpenAI 兼容的本地推理可以指向 LM Studio / vLLM / ollama(注意:ollama 是用户最初否决的"外接服务"路线,但如果作为 OpenAI 兼容端点
http://localhost:11434/v1用,本质上还是 OpenAI 协议,跟用户当前的"key + URL"心智模型一致)。代码不用改。 -
想加语音:Tauri 有麦克风权限 + WebAudio API,前端 capture → base64 →
ChatMessage.attachments走 OpenAI Whisper API。Speech synthesis 用 Web Speech API(speechSynthesis.speak())前端的 SpeechSynthesisUtterance。 -
bug 报告高频点:
- conf 改了不生效 → in-memory provider 不重读,必须重启
- 静默时段没声音 → 默认开 22:00-08:00,改
Behavior::new()时改默认值 - 窗口太大/太小 →
tauri.conf.json+AppConf双改 - 像素字体模糊 → 检查
factorRef缩放因子
10. 关键 commit-style 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 验证。