# EzVibeR+ 对话能力接入规划 > 状态:待执行 > 范围:仅 `EzVibeR+/` 目录 > 目标:桌宠(Live2D 窗口)新增持久聊天面板 + 后端接入本地 llama.cpp 内嵌推理 --- ## 0. 用户已确认的决策 | 决策点 | 值 | 备注 | |---|---|---| | 聊天面板 | 新建持久面板,**右侧**,宽 340px | 同一 live2d 窗口内 | | LLM 后端 | **llama.cpp 内嵌**(rust crate `llama-cpp-rs`) | 不用 Ollama | | 模型放置 | 用户手动放置,路径 `$HOME/.live2D/model/llm/` | 应用目录下 | | 模型文件命名 | `Qwen2.5-0.5B-Instruct-Q4_K_M.gguf` | 用户自行下载 | | 流式 vs 整段 | 整段返回 | 0.5B 推理 < 3s,没必要流式 | | 模型找不到 | 前端 chat 面板首条提示,附路径+复制按钮 | 不强报错 | | 加载时机 | **懒加载**:首次发消息时 load | 启动快 5-10s | | 推理后端 | **CPU only**(关 CUDA/Metal feature) | 后期可选 feature flag | | 构建工具链 | 需要 cmake + gcc/clang(首次编译)+20MB | 用户已同意 | | 上下文窗口 | 2048 tokens | `brain.rs::max_history=20` 兜底 | --- ## 1. 架构总览 ### 1.1 数据流 ``` 用户输入消息 │ ▼ [前端 ChatPanel.vue] │ invoke('chat', { message }) ▼ [Tauri commands::chat] ──→ brain.think(message, UserMessage) │ │ │ ▼ │ [AgentBrain] ← RAG 检索 / 历史追加 / Action 解析(不变) │ │ │ ▼ provider.chat(messages) │ [LlamaCppProvider] ★ 新建 │ │ │ ├─ 首次调用?→ load GGUF(懒加载,OnceCell) │ └─ 模型 prompt 拼装 → llama.cpp decode ▼ [BrainResponse { text, action, emotion_state, memory_id }] │ ▼ [前端 useChat.ts] 追加到 messages state → 渲染气泡 ``` ### 1.2 模块依赖关系 ``` main.rs └─> AgentBrain::new(provider, memory, config) └─> Arc └─> LlamaCppProvider::new(model_path) ★ 新增 frontend (live2d.html) └─> ├─ (原 index.vue canvas + 工具条) └─ ★ 新增 └─ useChat() ★ 新增 ``` --- ## 2. 文件变更清单 ### 2.1 后端 Rust(src-tauri/) | 文件 | 操作 | 内容 | |---|---|---| | `Cargo.toml` | 修改 | 新增 `llama-cpp-rs = "0.3"`,默认关 CUDA/Metal | | `src/utils.rs` | 修改 | 新增 `fn llm_model_dir() -> PathBuf` 返回 `$HOME/.live2D/model/llm/` | | `src/app/config.rs` | 修改 | `AppConf` 新增字段 `llm_model_path: String`(默认 `""` 表示自动用 `llm_model_dir`) | | `src/modules/llama_cpp.rs` | **新建** | 实现 `LLMProvider` trait 的 `LlamaCppProvider` | | `src/modules/mod.rs` | 修改 | `pub mod llama_cpp;` 并 re-export `LlamaCppProvider` | | `src/main.rs` | 修改 | `NoopLLMProvider` → `LlamaCppProvider::new(default_model_path())` | ### 2.2 前端 Vue(src/) | 文件 | 操作 | 内容 | |---|---|---| | `src/live2d/index.vue` | 修改 | 重构为 flex 左右布局:左 canvas + 工具条,右 `` | | `src/live2d/components/ChatPanel.vue` | **新建** | 消息流 + 输入框 + 状态指示器 | | `src/live2d/components/MessageBubble.vue` | **新建** | 单条消息气泡组件(用户/桌宠样式区分) | | `src/live2d/hooks/useChat.ts` | **新建** | 消息数组 state + invoke('chat') + 错误处理 | | `src/live2d/hooks/useModelStatus.ts` | **新建** | 通过 invoke('get_model_status') 检查模型是否加载 | ### 2.3 不动的文件 - `src-tauri/src/modules/brain.rs` 0 改动(已经是干净的 trait 抽象) - `src-tauri/src/app/commands.rs` 0 改动(chat 命令已存在) - `src-tauri/src/main.rs` 0 改动(仅替换 provider 类型) - `src/App.vue`, `src/main.ts` 不动(live2d 是独立入口) --- ## 3. 后端实现细节 ### 3.1 `src/modules/llama_cpp.rs` ```rust // 核心结构 pub struct LlamaCppProvider { model_path: PathBuf, // 配置或默认路径 model: OnceCell, // 懒加载容器 n_ctx: u32, // 2048 n_threads: u16, // 物理核心数 } impl LLMProvider for LlamaCppProvider { fn chat<'a>(&'a self, messages: &'a [ChatMessage]) -> Pin> + Send + 'a>> { Box::pin(async move { // 1. 懒加载:第一次进来时 load let model = self.model.get_or_try_init(|| { LlamaModel::load_from_file(&self.model_path, GgmlDType::F16, &LlamaParams::default().with_n_ctx(self.n_ctx)) .map_err(|e| BrainError::LLMError(format!("模型加载失败: {}", e))) })?; // 2. 拼 prompt(Qwen2.5 ChatML 格式) let prompt = build_qwen_chatml_prompt(messages); // 3. 推理(同步阻塞,单次 generate) let response = model.generate(prompt, GenerateParams::default() .with_max_tokens(512) .with_temperature(0.7) .with_stop(vec!["".into(), "<|im_end|>".into()])) .map_err(|e| BrainError::LLMError(format!("推理失败: {}", e)))?; // 4. strip ChatML 残留 Ok(strip_chatml_artifacts(&response)) }) } } ``` **Qwen2.5 ChatML prompt 模板**: ``` <|im_start|>system 你是一个名为 EzVibe 的桌面宠物助手。...<|im_end|> <|im_start|>user 【用户说】{user_input} 【相关记忆】 {...}<|im_end|> <|im_start|>assistant ``` **错误处理**: - 模型文件不存在 → `BrainError::LLMError("GGUF 模型未找到。\n\n请将模型文件放到:\n{path}\n\n文件名应为: Qwen2.5-0.5B-Instruct-Q4_K_M.gguf")` - 加载失败(损坏/版本不匹配)→ `BrainError::LLMError(...)` 带具体错误 - 推理超时 → `BrainError::LLMError("推理超时(>60s)")` ### 3.2 路径解析 ```rust // utils.rs 新增 pub fn llm_model_dir() -> PathBuf { app_root().join("model").join("llm") } pub fn default_llm_model_path() -> PathBuf { llm_model_dir().join("Qwen2.5-0.5B-Instruct-Q4_K_M.gguf") } ``` 启动时若目录不存在 → `fs::create_dir_all` 创建(虽然是手动放置,但目录是自动建的)。 ### 3.3 main.rs 改动 ```rust // 旧 let llm_provider: Arc = Arc::new(NoopLLMProvider); // 新 use crate::modules::LlamaCppProvider; use crate::utils::default_llm_model_path; let model_path = if !app_conf.llm_model_path.is_empty() { PathBuf::from(&app_conf.llm_model_path) } else { default_llm_model_path() }; // 确保目录存在 if let Some(parent) = model_path.parent() { let _ = std::fs::create_dir_all(parent); log::info!("📂 LLM 模型目录: {}", parent.display()); } log::info!("🤖 LLM 模型路径: {} (存在: {})", model_path.display(), model_path.exists()); let llm_provider: Arc = Arc::new(LlamaCppProvider::new(model_path)); ``` --- ## 4. 前端实现细节 ### 4.1 布局重构(`live2d/index.vue`) ```vue ``` **重要**:把 `data-tauri-drag-region="true"` **只放在** left-pane 的 canvas 上,**不放**在 right-pane 上 — 这样拖窗只对 Live2D 区生效,不会被聊天面板"偷走"。 ### 4.2 `ChatPanel.vue` 骨架 ```vue