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

17 KiB
Raw Permalink Blame History

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.rsChat Completions 协议)
  • 新增 src-tauri/src/modules/anthropic.rsMessages APIsystem 字段提到顶层)
  • 全部用 reqwest = "0.11" async HTTP
  • AppConfllm_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 onMounteddblclick 全局 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.rscrate 已不在依赖里,无影响)

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> 复杂度
  • 用户改完 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大写 DLinux 大小写敏感!)
  • 当前用户配置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不支持。如果以后真要本地直接换现代 forkllama-cpp-2 之类),并重写 llama_cpp.rs

  2. 想加 streaming:在 OpenAiProvider / AnthropicProvider 里改 stream: true,通过 reqwestbytes_stream() 逐 chunk 解析 SSEdata: {json}\n\n),每收到一个 chunk 就 emit 一个 Tauri event。Brain 那侧可以增加一个 streaming 路径。

  3. 想加多模态(图片)OpenAI/Anthropic 都支持 content: [{type: "text"}, {type: "image_url"}]。改 ChatMessageattachments: 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 APIspeechSynthesis.speak())前端的 SpeechSynthesisUtterance。

  6. bug 报告高频点

    • conf 改了不生效 → in-memory provider 不重读,必须重启
    • 静默时段没声音 → 默认开 22:00-08:00Behavior::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/v1Mymodel / EzVibe 模型)
401 错误友好提示API key 无效时显示在气泡里)
思考气泡可折叠DeepSeek-style 输出)
聊天面板收起/展开 + 模型自动重居中
双击不触发全屏
90s/180s 自动 reminder已生产化建议改回 45min/60min
手动 🔔 按钮触发 reminder
Anthropic 真实 key 测试(未做)
Streaming未做
多个不同 provider 切换(未做)


总结:核心对话能力跑通,从「不能对话」到「能对话」+「能健康提醒」+「思考可视化」+「UI 灵活」,用户反馈驱动迭代 6 轮完成。后续如要继续,主要方向是 streaming、GPU、Anthropic 验证。