Files
ShowenV2/src/plugins/ai/mod.rs

173 lines
5.9 KiB
Rust
Raw 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.
//! 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;
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,
whisper_lib_dir: Option<PathBuf>,
piper_lib_dir: Option<PathBuf>,
piper_config: Option<PathBuf>,
espeak_data_dir: Option<PathBuf>,
tmp_dir: PathBuf,
context_window: usize,
) -> Self {
let mut backend_builder = backend::LocalCliBackend::new(tools_dir.clone());
if let Some(lib_dir) = whisper_lib_dir {
backend_builder = backend_builder.with_whisper_lib_dir(lib_dir);
}
// piper 三个依赖必须同时设置
if let (Some(lib_dir), Some(config), Some(espeak_data)) = (piper_lib_dir, piper_config, espeak_data_dir) {
backend_builder = backend_builder.with_piper_deps(lib_dir, config, espeak_data);
}
let backend = Box::new(backend_builder);
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}");
}
}
_ => {}
}
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(())
}
}