feat(M2.1): AI 语音对话插件骨架 + 模型管理 + HTTP API + config schema 扩展

这是 M2.1(语音数字生命 V1)的后端骨架实现,不含 Web UI / Flutter / 设备部署。

新增文件:
- src/plugins/ai/mod.rs: AiPlugin 薄层(持有 ChatPipeline Arc,处理消息系统联动)
- src/plugins/ai/backend.rs: AiBackend trait + LocalCliBackend (whisper-cli/llama-cli/piper 子进程)
  + CloudBackend 占位(V1 返回"暂未支持")
- src/plugins/ai/chat.rs: ChatPipeline 对话管线执行器(HTTP 层 spawn_blocking 直接调用)
- src/plugins/ai/model_manager.rs: ModelManager 模型资产管理
  (清单/下载/切换/删除/配额/档位守门,参照 plugin_repo + version_manager 模式)

修改文件:
- src/core/message.rs: 新增 ChatRequest/ChatResponse/AiModelEvent 消息类型
- src/core/config.rs: AppConfig 新增 character 块(角色元信息+人设)+ ai 块(后端配置)
  均为 #[serde(default)] 向后兼容旧配置
- src/plugins/mod.rs: 注册 ai 模块
- src/plugins/http/mod.rs: HttpState 新增 ai_pipeline + ai_models 字段及注册方法;
  HttpPlugin 新增 set_ai_pipeline/set_ai_models 方法
- src/plugins/http/routes.rs: 新增 6 个 AI 相关路由
  - POST /api/chat/text (文字对话,Web 端主路径)
  - POST /api/chat/audio (语音对话,App 主路径)
  - GET /api/models (模型清单+水位+配额)
  - POST /api/models/download (下载模型,后台线程执行)
  - POST /api/models/switch (切换激活模型)
  - POST /api/models/delete (删除模型,保护当前激活)
- src/main.rs: 注册 AiPlugin,连接 pipeline 到 HttpPlugin

技术决策:
- 对话管线用 spawn_blocking 而非消息系统,满足 HTTP 同步响应需求
- ChatPipeline 用 Arc<Mutex> 共享,HTTP 和 AiPlugin 共用同一实例
- 互斥用 try_lock,忙时返回 409 而非阻塞
- Spike 结论固化: LLM t=2 锁大核、ctx=1024 限死、Qwen2.5-0.5B 默认档

待完成 (后续提交):
- Web 控制端 UI (文字对话 + 角色切换 + 模型管理界面)
- Flutter App (角色页/语音页/模型管理页)
- 设备端部署 llama.cpp/whisper.cpp/piper 二进制 + 模型下载
- 画面联动 (talk/idle 状态切换)
- 测试

注: Windows 开发环境缺 dbus,无法本地 cargo check;待目标机验证。
This commit is contained in:
2026-07-04 15:01:00 +08:00
parent b066dd187b
commit a0c4ca2307
10 changed files with 1487 additions and 6 deletions

161
src/plugins/ai/mod.rs Normal file
View File

@@ -0,0 +1,161 @@
//! AiPlugin — AI 语音对话插件 (M2.1)
//!
//! 设备本地跑 ASR (whisper.cpp) → LLM (llama.cpp) → TTS (piper) 管线,
//! 提供语音/文字对话回合能力。声音一律返回客户端播放,设备不出声。
//!
//! # 架构
//! ```text
//! HTTP /api/chat → ChatPipeline.run() → ASR → LLM → TTS → ChatResponse
//! (Arc<Mutex> 共享, HTTP 在 spawn_blocking 调用)
//! ```
//!
//! AiPlugin 本身是薄层:持有 ChatPipeline Arc 供 HTTP 层取用,
//! 并处理消息系统触发的联动(如 talk/idle 状态切换)。
//!
//! # 硬件约束 (Spike 2026-07-03 张明远实测)
//! - 测试机全志 A733: 2×A78@2.0G + 6×A55@1.8G, 4G 内存
//! - LLM 推理必须 t=2 锁大核 (全核反而崩且挤死视频)
//! - 严禁默认上下文长度 (4096/8192 会吃 1-3.3GB)
//! - 推荐默认 Qwen2.5-0.5B Q4_K_M (16.4 t/s, RSS 985M)
pub mod backend;
pub mod chat;
pub mod model_manager;
pub use chat::{ChatPipeline, SessionContext};
use crate::core::message::{Message, StateChanged};
use crate::core::plugin::*;
use anyhow::Result;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// AiPlugin — AI 对话插件(薄层)
///
/// 持有 ChatPipeline 的共享句柄,供 HTTP 层取用。
/// 自身处理消息系统触发的联动talk/idle 状态切换等)。
pub struct AiPlugin {
ctx: Option<PluginContext>,
/// 对话管线HTTP 层通过 Arc clone 直接调用)
pipeline: Arc<ChatPipeline>,
/// 工具目录
tools_dir: PathBuf,
}
impl AiPlugin {
/// 创建默认本地后端实例
pub fn new_default(
model_store: PathBuf,
tools_dir: PathBuf,
tmp_dir: PathBuf,
context_window: usize,
) -> Self {
let backend = Box::new(backend::LocalCliBackend::new(tools_dir.clone()));
let models = model_manager::ModelManager::new(model_store);
let pipeline = ChatPipeline::new(backend, models, tmp_dir, context_window);
Self {
ctx: None,
pipeline,
tools_dir,
}
}
/// 获取对话管线共享句柄main.rs 注册时传给 HttpPlugin
pub fn pipeline(&self) -> Arc<ChatPipeline> {
Arc::clone(&self.pipeline)
}
/// 获取模型管理器共享句柄main.rs 注册时传给 HttpPlugin
pub fn models(&self) -> Arc<Mutex<model_manager::ModelManager>> {
Arc::clone(&self.pipeline.models)
}
}
impl Plugin for AiPlugin {
fn id(&self) -> &str {
"ai"
}
fn info(&self) -> PluginInfo {
PluginInfo {
name: "AiPlugin".to_string(),
version: "0.1.0".to_string(),
description: "AI 语音对话 (ASR/LLM/TTS 本地推理)".to_string(),
platform: Platform::LinuxArm64,
}
}
fn capabilities(&self) -> Vec<String> {
vec!["chat".to_string(), "model_management".to_string()]
}
fn init(&mut self, ctx: PluginContext) -> Result<()> {
self.ctx = Some(ctx);
Ok(())
}
fn start(&mut self) -> Result<()> {
// 确保临时目录存在
if let Some(tmp) = self.pipeline.tmp_dir.parent() {
std::fs::create_dir_all(tmp).ok();
}
std::fs::create_dir_all(&self.pipeline.tmp_dir).ok();
// 预加载默认模型清单
let mut models = self.pipeline.models.lock().unwrap();
if let Err(e) = models.ensure_default_models() {
eprintln!("[AiPlugin] 警告: 模型初始化失败: {e}");
}
drop(models);
println!(
"[AiPlugin] 启动 (tools={}, tmp={})",
self.tools_dir.display(),
self.pipeline.tmp_dir.display()
);
Ok(())
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg {
Message::ChatRequest(req) => {
// 通过消息系统发起的对话(非 HTTP 路径),同步执行并广播结果
let resp = self.pipeline.run(&req);
if let Some(ctx) = &self.ctx {
let _ = ctx.tx.send(crate::core::message::Envelope {
from: self.id().to_string(),
to: crate::core::message::Destination::Broadcast,
message: Message::ChatResponse(resp),
});
}
}
Message::Shutdown => {
self.stop()?;
}
Message::StateChanged { old_state, new_state } => {
// 画面联动:进入/离开 talk 状态时记录日志(实际联动由 video 插件处理状态机)
if new_state == "talk" || old_state == "talk" {
println!("[AiPlugin] 画面状态: {old_state}{new_state}");
}
let _ = StateChanged { old_state, new_state }; // 抑制未用警告
}
_ => {}
}
Ok(())
}
fn stop(&mut self) -> Result<()> {
// 清理临时文件
if self.pipeline.tmp_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&self.pipeline.tmp_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("wav") {
let _ = std::fs::remove_file(&path);
}
}
}
}
Ok(())
}
}