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

381 lines
12 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.
//! AI 后端 trait + 本地命令行实现
//!
//! 抽象 ASR/LLM/TTS 三层支持本地命令行LocalCliBackend和未来云端后端。
//! V1 只实现 localcloud 配置时返回"暂未支持"错误。
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<String>;
/// 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<String>;
/// 文字转语音 (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,
/// whisper-cli 依赖的库路径libggml*.so 所在)
whisper_lib_dir: Option<PathBuf>,
/// piper 依赖的库路径libespeak-ng.so / libpiper_phonemize.so 所在)
piper_lib_dir: Option<PathBuf>,
/// piper 模型 config json 路径(.onnx.json
piper_config: Option<PathBuf>,
/// piper espeak-ng-data 目录路径
espeak_data_dir: Option<PathBuf>,
}
impl LocalCliBackend {
pub fn new(tools_dir: PathBuf) -> Self {
Self {
tools_dir,
whisper_lib_dir: None,
piper_lib_dir: None,
piper_config: None,
espeak_data_dir: None,
}
}
/// 设置 whisper-cli 依赖库路径(部署时由 deploy_ai.sh 标定)
pub fn with_whisper_lib_dir(mut self, lib_dir: PathBuf) -> Self {
self.whisper_lib_dir = Some(lib_dir);
self
}
/// 设置 piper 依赖库路径、config、espeak-ng-data 目录)
pub fn with_piper_deps(mut self, lib_dir: PathBuf, config: PathBuf, espeak_data: PathBuf) -> Self {
self.piper_lib_dir = Some(lib_dir);
self.piper_config = Some(config);
self.espeak_data_dir = Some(espeak_data);
self
}
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")
}
/// 给命令设置 LD_LIBRARY_PATH合并所有库路径
fn with_lib_path(&self, cmd: &mut Command, lib_dir: &Path) {
let existing = std::env::var("LD_LIBRARY_PATH").unwrap_or_default();
let new_path = if existing.is_empty() {
lib_dir.to_string_lossy().into_owned()
} else {
format!("{}:{}", lib_dir.to_string_lossy(), existing)
};
cmd.env("LD_LIBRARY_PATH", new_path);
}
}
impl AiBackend for LocalCliBackend {
fn asr(&self, audio_path: &Path, model_path: &Path) -> Result<String> {
// whisper-cli -m <model> -f <audio> -l zh --no-timestamps -otxt -of <tmp>
let tmp_out = audio_path.with_extension("asr.txt");
let mut cmd = Command::new(self.whisper_cli());
if let Some(lib_dir) = &self.whisper_lib_dir {
self.with_lib_path(&mut cmd, lib_dir);
}
let output = cmd
.arg("-m")
.arg(model_path)
.arg("-f")
.arg(audio_path)
.arg("-l")
.arg("zh")
.arg("--no-timestamps")
.arg("-otxt")
.arg("-of")
.arg(&tmp_out)
.output()?;
if !output.status.success() {
bail!(
"whisper-cli 失败: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let txt_path = tmp_out.with_extension("txt");
let text = std::fs::read_to_string(&txt_path)?;
let _ = std::fs::remove_file(&txt_path);
Ok(text.trim().to_string())
}
fn llm_chat(
&self,
model_path: &Path,
persona: &str,
user_message: &str,
history: &[(String, String)],
max_tokens: u16,
) -> Result<String> {
// 对齐 spike (2026-07-03) 验证的调用方式:
// llama-cli --single-turn --no-warmup -sys <persona> -p <user_msg> -n <tokens> -t 2 -c 1024 --temp 0.7
//
// 关键参数:
// --single-turn 单轮模式,生成完即退出(否则进交互模式等待 stdin永远不退出
// --no-warmup 跳过预热(减少延迟)
// -sys system prompt角色人设
// -p 用户输入(只传当前消息,历史通过 context 另传V1 简化)
//
// 历史上下文 V1 简化处理:拼到 -p 里(小模型 ctx 1024 有限,只保留最近 2 轮)
let mut prompt = String::new();
for (role, content) in history.iter().rev().take(4) {
// 逆序取最近 2 轮4 条消息),正序拼接
prompt.insert_str(0, &format!("{content}\n"));
}
prompt.push_str(user_message);
let tokens = if max_tokens == 0 { 128 } else { max_tokens as u32 };
let output = Command::new(self.llama_cli())
.arg("--single-turn")
.arg("--no-warmup")
.arg("-m")
.arg(model_path)
.arg("-sys")
.arg(persona)
.arg("-p")
.arg(&prompt)
.arg("-n")
.arg(tokens.to_string())
.arg("-t")
.arg("2") // 锁大核 (spike 结论: t=2 最优)
.arg("-c")
.arg("1024") // 限死上下文 (spike 警告: 默认 ctx 吃 1-3.3G)
.arg("--temp")
.arg("0.7")
.arg("--no-display-prompt")
.output()?;
if !output.status.success() {
bail!(
"llama-cli 失败 (exit={}): {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
// llama-cli --single-turn 输出格式:
// [banner/logo]
// > <user_input> ← 用户输入回显
// [进度动画 |/-\]
// <生成的回复> ← 我们要提取的部分
// [空行]
// [ Prompt: ... | Generation: ... ] ← 性能统计
//
// 解析策略:找到 "> " 开头的行,跳过进度动画行,取实际回复行
let raw = String::from_utf8_lossy(&output.stdout);
let reply = parse_llama_output(&raw);
Ok(reply)
}
fn tts(&self, text: &str, model_path: &Path, output_path: &Path) -> Result<()> {
// echo <text> | piper -m <model> [-c <config>] -f <output> --output_format wav
// piper 依赖 libespeak-ng.so / libpiper_phonemize.so需设 LD_LIBRARY_PATH
// piper 需要 ESPEAK_DATA_PATH 指向 espeak-ng-data 目录
let mut cmd = Command::new(self.piper());
if let Some(lib_dir) = &self.piper_lib_dir {
self.with_lib_path(&mut cmd, lib_dir);
}
if let Some(espeak_data) = &self.espeak_data_dir {
cmd.env("ESPEAK_DATA_PATH", espeak_data);
}
cmd.arg("-m")
.arg(model_path)
.arg("-f")
.arg(output_path)
.arg("--output_format")
.arg("wav");
// piper 需要 -c 指定 .onnx.json config 文件
if let Some(config) = &self.piper_config {
cmd.arg("-c").arg(config);
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin.write_all(text.as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
bail!(
"piper 失败 (exit={}): {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn backend_name(&self) -> &str {
"local"
}
}
/// 云端后端V1 仅占位,返回"暂未支持"
pub struct CloudBackend;
impl AiBackend for CloudBackend {
fn asr(&self, _audio_path: &Path, _model_path: &Path) -> Result<String> {
bail!("云端 ASR 暂未支持 (V1 仅实现 local)")
}
fn llm_chat(
&self,
_model_path: &Path,
_persona: &str,
_user_message: &str,
_history: &[(String, String)],
_max_tokens: u16,
) -> Result<String> {
bail!("云端 LLM 暂未支持 (V1 仅实现 local)")
}
fn tts(&self, _text: &str, _model_path: &Path, _output_path: &Path) -> Result<()> {
bail!("云端 TTS 暂未支持 (V1 仅实现 local)")
}
fn backend_name(&self) -> &str {
"cloud"
}
}
/// 解析 llama-cli --single-turn 的 stdout 输出,提取生成的回复文本
///
/// 输出格式:
/// ```text
/// [banner/logo ASCII art]
/// build : ...
/// model : ...
/// ...
/// > <user_input>
///
/// [进度动画 |/-\]
/// <生成的回复>
///
/// [ Prompt: ... | Generation: ... ]
///
/// Exiting...
/// ```
fn parse_llama_output(raw: &str) -> String {
let lines: Vec<&str> = raw.lines().collect();
let mut reply_lines = Vec::new();
let mut after_user_input = false;
for line in &lines {
let trimmed = line.trim();
// 跳过空行(但在回复区内保留)
if trimmed.is_empty() && !after_user_input {
continue;
}
// 找到用户输入回显行 "> xxx"
if trimmed.starts_with('>') {
after_user_input = true;
continue;
}
if !after_user_input {
continue;
}
// 遇到性能统计行 [ Prompt: ... ] 停止
if trimmed.starts_with('[') && trimmed.contains("Generation") {
break;
}
// 遇到 "Exiting..." 停止
if trimmed == "Exiting..." {
break;
}
// 清理进度动画字符 |/-\ 和退格符
// llama-cli 的进度动画用退格符 \u{8} 刷新stdout 捕获后保留为字面退格或 \b 转义
let cleaned: String = trimmed
.replace("\\b", "") // 字面 \b 转义序列
.replace("\u{8}", "") // 真正的退格符
.chars()
.filter(|c| !"|\\-/".contains(*c))
.collect::<String>();
let cleaned_str = cleaned.trim();
if cleaned_str.is_empty() {
continue;
}
reply_lines.push(cleaned_str.to_string());
}
reply_lines.join("\n").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_llama_output() {
// 进度动画和回复在同一行,用真正的退格符 \u{8} 分隔
let bs = "\u{8}"; // backspace
let raw = format!(
" build : b1-fdb1db8\n\
model : model_store/llm/qwen2.5-0.5b-q4_k_m.gguf\n\
\n\
> hello\n\
\n\
{bs}-{bs}|{bs}/{bs}-{bs}|{bs}/{bs} 汪!\n\
\n\
[ Prompt: 31.1 t/s | Generation: 26.3 t/s ]\n\
\n\
Exiting...\n"
);
let reply = parse_llama_output(&raw);
assert_eq!(reply, "汪!");
}
}