diff --git a/src/core/config.rs b/src/core/config.rs index 8cc152f..d7bb1dd 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -15,6 +15,12 @@ pub struct AppConfig { pub remote_control: RemoteControlConfig, #[serde(default)] pub ble: BleConfig, + /// 角色元信息 (M2.1 新增,向后兼容旧配置) + #[serde(default)] + pub character: CharacterConfig, + /// AI 对话配置 (M2.1 新增) + #[serde(default)] + pub ai: AiConfig, #[serde(default)] pub source_path: PathBuf, #[serde(default)] @@ -298,6 +304,104 @@ fn default_ble_device_name() -> String { "Showen".to_string() } +// ── 角色元信息 (M2.1) ── + +/// 角色类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum CharacterType { + #[default] + Pet, + Human, + Singer, +} + +/// 角色元信息配置块(内容包的一部分,切角色即切人设) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct CharacterConfig { + /// 角色显示名 (如 "小汪") + #[serde(default)] + pub name: String, + /// 角色类型 + #[serde(default)] + pub character_type: CharacterType, + /// 封面图相对路径 (内容包内) + #[serde(default)] + pub cover_image: Option, + /// 人设 prompt (LLM system prompt) + #[serde(default)] + pub persona_prompt: String, + /// 最大回复 token 数 + #[serde(default = "default_character_max_tokens")] + pub max_tokens: u16, + /// TTS 音色标识 (piper 模型 ID,留空用默认) + #[serde(default)] + pub tts_voice: Option, + /// talk 状态名 (语音回合期间切换到,留空用 "talk") + #[serde(default = "default_talk_state")] + pub talk_state: String, +} + +fn default_character_max_tokens() -> u16 { + 128 +} +fn default_talk_state() -> String { + "talk".to_string() +} + +// ── AI 配置 (M2.1) ── + +/// LLM 后端类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum LlmBackend { + #[default] + Local, + /// 云端 (V1 仅占位,配置时返回"暂未支持") + Cloud, +} + +/// AI 推理配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct AiConfig { + /// LLM 后端 (local/cloud) + #[serde(default)] + pub backend: LlmBackend, + /// 云端 endpoint (预留,V1 不实现) + #[serde(default)] + pub cloud_endpoint: Option, + /// 云端 API key (预留) + #[serde(default)] + pub cloud_api_key: Option, + /// 模型存储目录 (默认 model_store/) + #[serde(default = "default_model_store")] + pub model_store: PathBuf, + /// 工具目录 (whisper-cli/llama-cli/piper 二进制所在) + #[serde(default = "default_tools_dir")] + pub tools_dir: PathBuf, + /// 临时文件目录 + #[serde(default = "default_tmp_dir")] + pub tmp_dir: PathBuf, + /// 上下文窗口轮数 + #[serde(default = "default_context_window")] + pub context_window: usize, +} + +fn default_model_store() -> PathBuf { + PathBuf::from("model_store") +} +fn default_tools_dir() -> PathBuf { + PathBuf::from("tools") +} +fn default_tmp_dir() -> PathBuf { + PathBuf::from("/tmp/showen_ai") +} +fn default_context_window() -> usize { + 5 +} + // ── 加载与验证 ── impl AppConfig { diff --git a/src/core/message.rs b/src/core/message.rs index d69e520..35d2eb0 100644 --- a/src/core/message.rs +++ b/src/core/message.rs @@ -61,6 +61,14 @@ pub enum Message { DeviceResponse(DeviceResponse), DeviceEvent(DeviceEvent), + // ── AI 语音对话 (M2.1) ── + /// 语音/文字对话回合请求(HTTP → AI 插件) + ChatRequest(ChatRequest), + /// 对话回合响应(AI 插件 → HTTP/广播) + ChatResponse(ChatResponse), + /// AI 模型状态变更广播(加载/切换/下载进度) + AiModelEvent(AiModelEvent), + // ── 扩展(未来插件用) ── Custom { kind: String, @@ -68,6 +76,62 @@ pub enum Message { }, } +/// 对话回合请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatRequest { + /// 会话标识(同一会话保留上下文) + pub session_id: String, + /// 输入类型:文字或音频文件路径(设备临时文件) + pub input: ChatInput, + /// 当前角色人设 prompt + pub persona_prompt: String, + /// 最大回复 token 数(0 表示用默认) + pub max_tokens: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ChatInput { + /// 文字输入(Web 端主路径) + Text { content: String }, + /// 音频文件路径(App 上传后的临时文件,16kHz mono wav) + Audio { path: String }, +} + +/// 对话回合响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatResponse { + pub session_id: String, + /// ASR 转写文本(音频输入时,文字输入时为 None) + pub transcription: Option, + /// LLM 回复文本 + pub reply_text: String, + /// TTS 回复音频文件路径(设备临时文件,HTTP 返回后由客户端播放) + pub reply_audio_path: Option, + /// 错误信息(非空表示失败) + pub error: Option, +} + +/// AI 模型事件 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum AiModelEvent { + /// 模型加载中 + Loading { model_id: String }, + /// 模型已就绪 + Ready { model_id: String }, + /// 模型切换 + Switched { old: String, new: String }, + /// 下载进度 + DownloadProgress { + model_id: String, + downloaded: u64, + total: u64, + }, + /// 错误 + Error { model_id: String, message: String }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PlayerCommand { Play, diff --git a/src/main.rs b/src/main.rs index 5562f84..55fb542 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use showen_v2::core::service_manager::ServiceManager; #[cfg(not(test))] use showen_v2::core::version_manager::VersionManager; use showen_v2::plugins::{ - ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin, + ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin, }; use std::sync::atomic::{AtomicBool, Ordering}; @@ -49,7 +49,7 @@ fn main() -> Result<()> { let mut manager = ServiceManager::new(config); // 按依赖顺序注册插件 - // 独立插件:device, screen, wifi, video, ble + // 独立插件:device, screen, wifi, video, ble, ai // 依赖插件:http (依赖 video) println!("注册插件..."); @@ -68,7 +68,25 @@ fn main() -> Result<()> { manager.register(Box::new(BlePlugin::new())); println!(" ✓ BlePlugin"); - manager.register(Box::new(HttpPlugin::new())); + // AiPlugin: 需要在注册前创建实例以获取 pipeline 句柄,再注册进 manager + // pipeline 会传给 HttpPlugin,建立 HTTP → AI 的直接通道 + let ai_config = &config.ai; + let ai_plugin = AiPlugin::new_default( + ai_config.model_store.clone(), + ai_config.tools_dir.clone(), + ai_config.tmp_dir.clone(), + ai_config.context_window, + ); + let ai_pipeline = ai_plugin.pipeline(); + let ai_models = ai_plugin.models(); + manager.register(Box::new(ai_plugin)); + println!(" ✓ AiPlugin"); + + let mut http_plugin = HttpPlugin::new(); + // 注册 AI 句柄到 HttpState(在 http init 之前建立连接) + http_plugin.set_ai_pipeline(ai_pipeline); + http_plugin.set_ai_models(ai_models); + manager.register(Box::new(http_plugin)); println!(" ✓ HttpPlugin"); // 加载动态插件 diff --git a/src/plugins/ai/backend.rs b/src/plugins/ai/backend.rs new file mode 100644 index 0000000..e7cdf5d --- /dev/null +++ b/src/plugins/ai/backend.rs @@ -0,0 +1,213 @@ +//! AI 后端 trait + 本地命令行实现 +//! +//! 抽象 ASR/LLM/TTS 三层,支持本地命令行(LocalCliBackend)和未来云端后端。 +//! V1 只实现 local;cloud 配置时返回"暂未支持"错误。 + +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// AI 后端抽象 trait +pub trait AiBackend: Send { + /// 语音转文字 (ASR) + /// - audio_path: 16kHz mono wav 临时文件 + /// - model_path: whisper.cpp 模型 (ggml-tiny.bin 等) + fn asr(&self, audio_path: &Path, model_path: &Path) -> Result; + + /// LLM 对话 + /// - model_path: llama.cpp GGUF 模型 + /// - persona: 角色人设 system prompt + /// - user_message: 本轮用户输入 + /// - history: 历史轮次 (role, content) + /// - max_tokens: 最大回复 token (0=默认 128) + fn llm_chat( + &self, + model_path: &Path, + persona: &str, + user_message: &str, + history: &[(String, String)], + max_tokens: u16, + ) -> Result; + + /// 文字转语音 (TTS) + /// - text: 要合成的文本 + /// - model_path: piper 模型目录/文件 + /// - output_path: 输出 wav 文件路径 + fn tts(&self, text: &str, model_path: &Path, output_path: &Path) -> Result<()>; + + /// 后端标识(local / cloud) + fn backend_name(&self) -> &str; +} + +/// 本地命令行后端 +/// +/// 通过子进程调用 whisper-cli / llama-cli / piper 二进制。 +/// Spike (2026-07-03) 验证的命令行参数固化于此。 +pub struct LocalCliBackend { + /// 工具目录(含 whisper-cli, llama-cli, piper 二进制) + tools_dir: PathBuf, +} + +impl LocalCliBackend { + pub fn new(tools_dir: PathBuf) -> Self { + Self { tools_dir } + } + + fn whisper_cli(&self) -> PathBuf { + self.tools_dir.join("whisper-cli") + } + + fn llama_cli(&self) -> PathBuf { + self.tools_dir.join("llama-cli") + } + + fn piper(&self) -> PathBuf { + self.tools_dir.join("piper") + } +} + +impl AiBackend for LocalCliBackend { + fn asr(&self, audio_path: &Path, model_path: &Path) -> Result { + // whisper-cli -m -f