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:
@@ -1,7 +1,7 @@
|
||||
use super::HttpState;
|
||||
use crate::core::config::{self, AppConfig};
|
||||
use crate::core::dispatch;
|
||||
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
||||
use crate::core::message::{ChatInput, ChatRequest, Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
||||
use bytes::Buf;
|
||||
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -145,7 +145,15 @@ pub(crate) fn build_routes(
|
||||
.or(file_mkdir_route(Arc::clone(&state)))
|
||||
.boxed();
|
||||
|
||||
let api = core_api.or(media_api).or(plugin_api).or(file_api);
|
||||
let ai_api = chat_text_route(Arc::clone(&state))
|
||||
.or(chat_audio_route(Arc::clone(&state)))
|
||||
.or(models_list_route(Arc::clone(&state)))
|
||||
.or(model_download_route(Arc::clone(&state)))
|
||||
.or(model_switch_route(Arc::clone(&state)))
|
||||
.or(model_delete_route(Arc::clone(&state)))
|
||||
.boxed();
|
||||
|
||||
let api = core_api.or(media_api).or(plugin_api).or(file_api).or(ai_api);
|
||||
|
||||
root_route()
|
||||
.or(download_route(Arc::clone(&state)))
|
||||
@@ -1954,6 +1962,270 @@ async fn send_plugin_command(
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI 对话 API (M2.1) ──
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatTextRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// POST /api/chat/text — 文字对话(Web 端主路径)
|
||||
fn chat_text_route(
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "chat" / "text")
|
||||
.and(warp::post())
|
||||
.and(warp::body::json::<ChatTextRequest>())
|
||||
.and(with_state(state))
|
||||
.and_then(|req: ChatTextRequest, state: Arc<HttpState>| async move {
|
||||
let pipeline = match state.ai_pipeline() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok::<_, Infallible>(error_json(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI 插件未就绪",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let config = state.config();
|
||||
let session_id = req
|
||||
.session_id
|
||||
.unwrap_or_else(|| format!("web_{}", std::process::id()));
|
||||
let chat_req = ChatRequest {
|
||||
session_id,
|
||||
input: ChatInput::Text {
|
||||
content: req.text,
|
||||
},
|
||||
persona_prompt: config.character.persona_prompt.clone(),
|
||||
max_tokens: config.character.max_tokens,
|
||||
};
|
||||
|
||||
// AI 管线是同步阻塞调用(ASR/LLM/TTS 子进程),用 spawn_blocking 避免阻塞 tokio reactor
|
||||
let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req))
|
||||
.await
|
||||
.unwrap_or_else(|e| ChatResponse {
|
||||
session_id: String::new(),
|
||||
transcription: None,
|
||||
reply_text: String::new(),
|
||||
reply_audio_path: None,
|
||||
error: Some(format!("AI 管线执行失败: {e}")),
|
||||
});
|
||||
|
||||
if resp.error.is_some() {
|
||||
Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp))
|
||||
} else {
|
||||
Ok(json_response(StatusCode::OK, &resp))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /api/chat/audio — 语音对话(App 主路径,上传 wav)
|
||||
fn chat_audio_route(
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "chat" / "audio")
|
||||
.and(warp::post())
|
||||
.and(warp::body::content_length_limit(20 * 1024 * 1024))
|
||||
.and(warp::body::bytes())
|
||||
.and(with_state(state))
|
||||
.and_then(|bytes: bytes::Bytes, state: Arc<HttpState>| async move {
|
||||
let pipeline = match state.ai_pipeline() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok::<_, Infallible>(error_json(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI 插件未就绪",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let config = state.config();
|
||||
let tmp_dir = config.ai.tmp_dir.clone();
|
||||
let _ = std::fs::create_dir_all(&tmp_dir);
|
||||
let audio_path = tmp_dir.join(format!(
|
||||
"asr_{}.wav",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
if let Err(e) = std::fs::write(&audio_path, &bytes) {
|
||||
return Ok(error_json(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("保存音频失败: {e}"),
|
||||
));
|
||||
}
|
||||
|
||||
let session_id = format!("app_{}", std::process::id());
|
||||
let chat_req = ChatRequest {
|
||||
session_id,
|
||||
input: ChatInput::Audio {
|
||||
path: audio_path.to_string_lossy().into_owned(),
|
||||
},
|
||||
persona_prompt: config.character.persona_prompt.clone(),
|
||||
max_tokens: config.character.max_tokens,
|
||||
};
|
||||
|
||||
let audio_path_clone = audio_path.clone();
|
||||
let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req))
|
||||
.await
|
||||
.unwrap_or_else(|e| ChatResponse {
|
||||
session_id: String::new(),
|
||||
transcription: None,
|
||||
reply_text: String::new(),
|
||||
reply_audio_path: None,
|
||||
error: Some(format!("AI 管线执行失败: {e}")),
|
||||
});
|
||||
|
||||
// 清理上传的临时音频
|
||||
let _ = std::fs::remove_file(&audio_path_clone);
|
||||
|
||||
if resp.error.is_some() {
|
||||
Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp))
|
||||
} else {
|
||||
Ok(json_response(StatusCode::OK, &resp))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── AI 模型管理 API (M2.1) ──
|
||||
|
||||
/// GET /api/models — 列出所有模型
|
||||
fn models_list_route(
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "models")
|
||||
.and(warp::get())
|
||||
.and(with_state(state))
|
||||
.and_then(|state: Arc<HttpState>| async move {
|
||||
let models = match state.ai_models() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok::<_, Infallible>(error_json(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI 插件未就绪",
|
||||
))
|
||||
}
|
||||
};
|
||||
let mgr = models.lock().unwrap();
|
||||
let registry = mgr.registry();
|
||||
let used = mgr.used_space();
|
||||
let quota = mgr.quota();
|
||||
let result = serde_json::json!({
|
||||
"models": registry.models,
|
||||
"active": registry.active,
|
||||
"used_space": used,
|
||||
"quota": quota,
|
||||
});
|
||||
Ok(json_response(StatusCode::OK, &result))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ModelActionRequest {
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
/// POST /api/models/download — 下载模型
|
||||
fn model_download_route(
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "models" / "download")
|
||||
.and(warp::post())
|
||||
.and(warp::body::json::<ModelActionRequest>())
|
||||
.and(with_state(state))
|
||||
.and_then(|req: ModelActionRequest, state: Arc<HttpState>| async move {
|
||||
let models = match state.ai_models() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok::<_, Infallible>(error_json(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI 插件未就绪",
|
||||
))
|
||||
}
|
||||
};
|
||||
// 下载在独立线程执行,避免阻塞 HTTP
|
||||
let models_clone = models.clone();
|
||||
let model_id = req.model_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut mgr = models_clone.lock().unwrap();
|
||||
if let Err(e) = mgr.download_model(&model_id) {
|
||||
eprintln!("[HttpPlugin] 模型下载失败 {model_id}: {e}");
|
||||
}
|
||||
});
|
||||
Ok::<_, Infallible>(success_json(format!("模型 {} 下载已启动", req.model_id)))
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /api/models/switch — 切换激活模型
|
||||
fn model_switch_route(
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "models" / "switch")
|
||||
.and(warp::post())
|
||||
.and(warp::body::json::<serde_json::Value>())
|
||||
.and(with_state(state))
|
||||
.and_then(|body: serde_json::Value, state: Arc<HttpState>| async move {
|
||||
let models = match state.ai_models() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok::<_, Infallible>(error_json(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI 插件未就绪",
|
||||
))
|
||||
}
|
||||
};
|
||||
let model_id = match body.get("model_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 model_id")),
|
||||
};
|
||||
let kind_str = match body.get("kind").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 kind")),
|
||||
};
|
||||
let kind = match kind_str {
|
||||
"llm" => crate::plugins::ai::model_manager::ModelKind::Llm,
|
||||
"asr" => crate::plugins::ai::model_manager::ModelKind::Asr,
|
||||
"tts" => crate::plugins::ai::model_manager::ModelKind::Tts,
|
||||
_ => return Ok(error_json(StatusCode::BAD_REQUEST, "kind 必须为 llm/asr/tts")),
|
||||
};
|
||||
let mut mgr = models.lock().unwrap();
|
||||
match mgr.switch_model(kind, &model_id) {
|
||||
Ok(()) => Ok(success_json(format!("模型已切换为 {}", model_id))),
|
||||
Err(e) => Ok(error_json(StatusCode::BAD_REQUEST, &e.to_string())),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /api/models/delete — 删除模型
|
||||
fn model_delete_route(
|
||||
state: Arc<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "models" / "delete")
|
||||
.and(warp::post())
|
||||
.and(warp::body::json::<ModelActionRequest>())
|
||||
.and(with_state(state))
|
||||
.and_then(|req: ModelActionRequest, state: Arc<HttpState>| async move {
|
||||
let models = match state.ai_models() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok::<_, Infallible>(error_json(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"AI 插件未就绪",
|
||||
))
|
||||
}
|
||||
};
|
||||
let mut mgr = models.lock().unwrap();
|
||||
match mgr.delete_model(&req.model_id) {
|
||||
Ok(()) => Ok(success_json(format!("模型 {} 已删除", req.model_id))),
|
||||
Err(e) => Ok(error_json(StatusCode::BAD_REQUEST, &e.to_string())),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
|
||||
warp::reply::with_status(warp::reply::json(payload), status).into_response()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user