381 lines
12 KiB
Rust
381 lines
12 KiB
Rust
//! 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<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, "汪!");
|
||
}
|
||
}
|