翻译模块

This commit is contained in:
zhongluofeng
2026-09-17 17:32:20 +08:00
parent 79c43d5340
commit f6c1cc250e
47 changed files with 10822 additions and 104 deletions
+593
View File
@@ -0,0 +1,593 @@
//! OpenAI 兼容引擎。
//!
//! 一套代码覆盖 DeepSeek / OpenAI / 通义(DashScope 兼容模式)/ Kimi / 智谱 /
//! 本地 Ollama / LM Studio / one-api 等中转服务——它们都提供
//! `POST {baseUrl}/chat/completions` 且请求响应结构一致,因此差异只在配置项里。
//!
//! 翻译场景刻意**不开思考模式**:不写 `thinking` / `reasoning_effort`,换低延迟与低费用。
//! 若用户确实需要,可通过 `extra_body`JSON 文本)自行透传。
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::json;
use super::{
apply_common_params, build_user_content, render_template, EngineRequest, ErrorKind, TokenUsage,
TranslateEngine, TranslateError, TranslateMode, TranslateResult,
};
use crate::translate::settings::{PromptTemplates, TranslateEngineConfig};
/// 自检与连通性测试使用的探测文本
const PROBE_TEXT: &str = "Hello, world.";
pub struct AiEngine {
cfg: TranslateEngineConfig,
templates: PromptTemplates,
client: reqwest::Client,
}
impl AiEngine {
pub fn new(
cfg: TranslateEngineConfig,
templates: PromptTemplates,
client: reqwest::Client,
) -> Self {
Self {
cfg,
templates,
client,
}
}
/// API 根地址。容错处理:用户常把完整端点(`.../chat/completions`)直接粘进来,
/// 若不在末尾剥掉,就会拼出 `.../chat/completions/chat/completions`
/// 而这类错误在上游表现为 404,排查成本远高于此处一行判断。
fn api_root(&self) -> Result<String, TranslateError> {
let raw = self.cfg.base_url.trim();
if raw.is_empty() {
return Err(TranslateError::config(format!(
"引擎「{}」尚未配置 Base URL",
self.cfg.name
)));
}
if !(raw.starts_with("http://") || raw.starts_with("https://")) {
return Err(TranslateError::config(format!(
"Base URL 需以 http:// 或 https:// 开头,当前为「{raw}"
)));
}
let mut root = raw.trim_end_matches('/').to_string();
for suffix in ["/chat/completions", "/completions", "/models"] {
if let Some(stripped) = root.strip_suffix(suffix) {
root = stripped.trim_end_matches('/').to_string();
break;
}
}
Ok(root)
}
fn chat_endpoint(&self) -> Result<String, TranslateError> {
Ok(format!("{}/chat/completions", self.api_root()?))
}
fn models_endpoint(&self) -> Result<String, TranslateError> {
Ok(format!("{}/models", self.api_root()?))
}
/// 密钥只从系统凭据管理器读,不进配置文件、不经前端。
fn api_key(&self) -> String {
crate::translate::engine_api_key(&self.cfg.id)
}
fn require_key(&self) -> Result<String, TranslateError> {
let key = self.api_key();
if key.trim().is_empty() {
return Err(TranslateError::auth(format!(
"引擎「{}」尚未配置 API Key,请在翻译设置中填写",
self.cfg.name
)));
}
Ok(key)
}
fn require_model(&self) -> Result<String, TranslateError> {
let model = self.cfg.model.trim();
if model.is_empty() {
return Err(TranslateError::config(format!(
"引擎「{}」尚未选择模型,可在设置中拉取模型列表后选择",
self.cfg.name
)));
}
Ok(model.to_string())
}
/// 有效 system prompt:实例级自定义提示词优先,否则用模式对应的全局模板。
fn system_prompt(&self, req: &EngineRequest) -> String {
if !self.cfg.system_prompt.trim().is_empty() {
return render_template(&self.cfg.system_prompt, req);
}
render_template(self.templates.for_mode(req.mode), req)
}
/// 流式收尾。空内容不直接判死:**自动改用同步接口重试一次**。
///
/// 上游偶发「流正常结束但 content 为空」(安全拦截、思维链吃满 max_tokens、
/// 中转服务抖动都会导致)。用户视角这与请求失败无异,但同步接口往往能正常
/// 返回——与其抛错让人手动重试,不如在这里自愈一次。重试失败才把错误交给上层。
async fn finish_stream_with_fallback(
&self,
tx: &tokio::sync::mpsc::Sender<super::StreamEvent>,
request_id: String,
content: String,
req: &EngineRequest,
started: Instant,
finish_reason: Option<String>,
) -> Result<(), TranslateError> {
if content.trim().is_empty() {
if tx.is_closed() {
// 调用方已放弃(停止 / 新请求顶替),不再花一次 API 调用
return Ok(());
}
crate::logger::log_warn(
"translate",
&format!(
"{}」流式返回为空(finish_reason={:?}),自动改用同步接口重试",
self.cfg.name, finish_reason
),
);
// 同步路径的错误更具体(认证/额度/响应解析都能区分),直接透传
let result = self.translate(req).await?;
let _ = tx.send(super::StreamEvent::Done { request_id, result }).await;
return Ok(());
}
finish_stream(
tx,
request_id,
content,
req,
&self.cfg,
started.elapsed().as_millis() as u64,
)
.await
}
}
#[async_trait::async_trait]
impl TranslateEngine for AiEngine {
fn config(&self) -> &TranslateEngineConfig {
&self.cfg
}
async fn translate(&self, req: &EngineRequest) -> Result<TranslateResult, TranslateError> {
if req.text.trim().is_empty() {
return Err(TranslateError::empty());
}
let key = self.require_key()?;
let model = self.require_model()?;
let endpoint = self.chat_endpoint()?;
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!(model));
body.insert("stream".to_string(), json!(false));
body.insert(
"messages".to_string(),
json!([
{ "role": "system", "content": self.system_prompt(req) },
{ "role": "user", "content": build_user_content(req, self.cfg.supports_vision) },
]),
);
apply_common_params(&mut body, &self.cfg);
let started = Instant::now();
let resp = self
.client
.post(&endpoint)
.bearer_auth(&key)
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(1000)))
.json(&serde_json::Value::Object(body))
.send()
.await
.map_err(|e| classify_reqwest(e, &self.cfg.name))?;
let status = resp.status();
let raw = resp
.text()
.await
.map_err(|e| TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name)))?;
if !status.is_success() {
return Err(classify_http(status.as_u16(), &raw, &self.cfg.name, &model));
}
let parsed: ChatResponse = serde_json::from_str(&raw).map_err(|e| {
TranslateError::parse(format!("{}」响应不是预期的 JSON: {e}", self.cfg.name))
.with_detail(&raw)
})?;
if let Some(err) = parsed.error {
let msg = err
.message
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| "上游返回了错误对象".to_string());
return Err(
TranslateError::new(ErrorKind::Unknown, format!("{}」返回错误:{msg}", self.cfg.name))
.with_detail(&raw),
);
}
let text = parsed
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(TranslateError::empty)?;
let latency_ms = started.elapsed().as_millis() as u64;
let usage = parsed.usage.map(|u| {
let total = if u.total_tokens > 0 {
u.total_tokens
} else {
u.prompt_tokens + u.completion_tokens
};
TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: total,
}
});
Ok(TranslateResult {
text,
// AI 引擎不做语言检测:显式指定了源语言时回显,auto 时留空由前端展示「自动」
detected: if req.from.trim().is_empty() || req.from == "auto" {
None
} else {
Some(req.from.clone())
},
engine_id: self.cfg.id.clone(),
engine_name: self.cfg.name.clone(),
latency_ms,
usage,
})
}
/// 流式翻译(SSE)。增量经通道下发,`data: [DONE]` 或流结束时发 Done。
///
/// 错误处理约定:**本方法不发送 `StreamEvent::Error`**——任何失败都通过 `Err` 返回,
/// 由命令层统一转成错误事件,避免前端收到两条错误。通道关闭(调用方已放弃,
/// 例如用户点了停止)时安静返回 `Ok(())`,不当作失败。
async fn translate_stream(
&self,
req: &EngineRequest,
request_id: String,
tx: tokio::sync::mpsc::Sender<super::StreamEvent>,
) -> Result<(), TranslateError> {
if req.text.trim().is_empty() && req.image_png.is_none() {
return Err(TranslateError::empty());
}
let key = self.require_key()?;
let model = self.require_model()?;
let endpoint = self.chat_endpoint()?;
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!(model));
body.insert("stream".to_string(), json!(true));
body.insert(
"messages".to_string(),
json!([
{ "role": "system", "content": self.system_prompt(req) },
{ "role": "user", "content": build_user_content(req, self.cfg.supports_vision) },
]),
);
apply_common_params(&mut body, &self.cfg);
let started = Instant::now();
let resp = self
.client
.post(&endpoint)
.bearer_auth(&key)
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(1000)))
.json(&serde_json::Value::Object(body))
.send()
.await
.map_err(|e| classify_reqwest(e, &self.cfg.name))?;
let status = resp.status();
if !status.is_success() {
let raw = resp.text().await.unwrap_or_default();
return Err(classify_http(status.as_u16(), &raw, &self.cfg.name, &model));
}
// SSE 逐行解析:字节块可能把一行劈成两半,必须先攒缓冲再按 \n 切
use futures_util::StreamExt;
let mut stream = resp.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
let mut content = String::new();
let mut finish_reason: Option<String> = None;
while let Some(item) = stream.next().await {
let bytes =
item.map_err(|e| TranslateError::network(format!("读取流失败: {e}")))?;
buffer.extend_from_slice(&bytes);
while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len() - 1]);
let Some(payload) = line.trim().strip_prefix("data:") else {
continue;
};
let payload = payload.trim();
if payload.is_empty() {
continue;
}
if payload == "[DONE]" {
return self
.finish_stream_with_fallback(
&tx,
request_id,
content,
req,
started,
finish_reason,
)
.await;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(payload) else {
continue;
};
// 上游在流中携带错误对象时终止
if let Some(message) = value
.pointer("/error/message")
.and_then(|m| m.as_str())
.filter(|m| !m.trim().is_empty())
{
return Err(TranslateError::new(
ErrorKind::Unknown,
format!("{}」流中返回错误:{message}", self.cfg.name),
));
}
if let Some(fr) = value
.pointer("/choices/0/finish_reason")
.and_then(|v| v.as_str())
{
finish_reason = Some(fr.to_string());
}
let Some(delta) = value
.pointer("/choices/0/delta/content")
.and_then(|c| c.as_str())
.filter(|d| !d.is_empty())
else {
continue;
};
content.push_str(delta);
if tx
.send(super::StreamEvent::Chunk {
request_id: request_id.clone(),
delta: delta.to_string(),
})
.await
.is_err()
{
// 通道已关 = 调用方放弃(停止 / 新请求顶替),安静退出
return Ok(());
}
}
}
// 流结束但没收到 [DONE]:部分上游异常断流。已有内容仍视为成功,
// 否则用户会看着已译出一半的结果被告知失败。
self.finish_stream_with_fallback(
&tx,
request_id,
content,
req,
started,
finish_reason,
)
.await
}
async fn list_models(&self) -> Result<Vec<String>, TranslateError> {
let key = self.require_key()?;
let endpoint = self.models_endpoint()?;
let resp = self
.client
.get(&endpoint)
.bearer_auth(&key)
.timeout(Duration::from_millis(self.cfg.timeout_ms.clamp(3_000, 15_000)))
.send()
.await
.map_err(|e| classify_reqwest(e, &self.cfg.name))?;
let status = resp.status();
let raw = resp
.text()
.await
.map_err(|e| TranslateError::network(format!("读取「{}」模型列表失败: {e}", self.cfg.name)))?;
if !status.is_success() {
return Err(classify_http(
status.as_u16(),
&raw,
&self.cfg.name,
self.cfg.model.as_str(),
));
}
let parsed: ModelsResponse = serde_json::from_str(&raw).map_err(|e| {
TranslateError::parse(format!("{}」的模型列表无法解析: {e}", self.cfg.name))
.with_detail(&raw)
})?;
let mut ids: Vec<String> = parsed
.data
.into_iter()
.map(|m| m.id)
.filter(|id| !id.trim().is_empty())
.collect();
ids.sort();
ids.dedup();
Ok(ids)
}
async fn test(&self) -> Result<String, TranslateError> {
let req = EngineRequest {
text: PROBE_TEXT.to_string(),
from: "en".to_string(),
to: "zh-Hans".to_string(),
to_label: "简体中文".to_string(),
from_label: "英语".to_string(),
mode: TranslateMode::Translate,
image_png: None,
via: "preview".to_string(),
};
let started = Instant::now();
let result = self.translate(&req).await?;
let total = started.elapsed().as_millis() as u64;
Ok(format!(
"连通正常 · 模型 {} · {}ms · 回显「{}",
self.cfg.model, total, result.text
))
}
}
/// 收尾:把累积内容封装成结果下发。空内容按「上游没产出」处理。
async fn finish_stream(
tx: &tokio::sync::mpsc::Sender<super::StreamEvent>,
request_id: String,
content: String,
req: &EngineRequest,
cfg: &TranslateEngineConfig,
latency_ms: u64,
) -> Result<(), TranslateError> {
let text = content.trim().to_string();
if text.is_empty() {
return Err(TranslateError::new(
ErrorKind::Empty,
format!("{}」未返回任何译文(流提前结束)", cfg.name),
));
}
let _ = tx
.send(super::StreamEvent::Done {
request_id,
result: TranslateResult {
text,
detected: if req.from.trim().is_empty() || req.from == "auto" {
None
} else {
Some(req.from.clone())
},
engine_id: cfg.id.clone(),
engine_name: cfg.name.clone(),
latency_ms,
// 流式路径不索取 usagestream_options 是各家扩展,兼容性不一
usage: None,
},
})
.await;
Ok(())
}
/// 网络层错误分类。
fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError {
if e.is_timeout() {
return TranslateError::timeout(format!(
"请求「{engine}」超时,可在引擎设置中调大超时时间,或检查网络"
));
}
if e.is_connect() {
return TranslateError::network(format!(
"无法连接「{engine}」:{e}(若该服务在境外,请检查网络或开启代理)"
));
}
TranslateError::network(format!("请求「{engine}」失败:{e}"))
}
/// HTTP 状态码分类。把「Key 不对」「模型名不对」「被限流」分开,
/// 是因为这三者在前端的处置动作完全不同。
fn classify_http(status: u16, body: &str, engine: &str, model: &str) -> TranslateError {
let snippet = body.trim();
let lower = snippet.to_lowercase();
let err = match status {
401 | 403 => TranslateError::auth(format!(
"{engine}」认证失败(HTTP {status}),请检查 API Key 是否正确、是否有该模型的权限"
)),
402 => TranslateError::auth(format!("{engine}」余额不足或未开通计费(HTTP 402)")),
429 => TranslateError::rate_limit(format!(
"{engine}」请求过于频繁(HTTP 429),请稍后重试或降低频率"
)),
400 | 404 | 422 => {
if lower.contains("model") {
TranslateError::config(format!(
"{engine}」不识别模型「{model}」(HTTP {status}):上游模型名可能已变更,\
请在设置中拉取模型列表后重新选择"
))
} else {
TranslateError::new(
ErrorKind::Config,
format!("{engine}」拒绝了该请求(HTTP {status}"),
)
}
}
408 | 504 => TranslateError::timeout(format!("{engine}」上游超时(HTTP {status}")),
s if (500..600).contains(&s) => {
TranslateError::network(format!("{engine}」服务端错误(HTTP {status}),可稍后重试"))
}
s => TranslateError::new(ErrorKind::Unknown, format!("{engine}」返回 HTTP {s}")),
};
err.with_detail(snippet)
}
// ===== 响应结构(宽松解析:缺字段不报错,由业务层判断内容是否可用) =====
#[derive(Debug, Deserialize)]
struct ChatResponse {
#[serde(default)]
choices: Vec<ChatChoice>,
#[serde(default)]
usage: Option<ChatUsage>,
#[serde(default)]
error: Option<ApiErrorBody>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
#[serde(default)]
message: ChatMessage,
}
#[derive(Debug, Default, Deserialize)]
struct ChatMessage {
/// 部分实现会返回 null(例如只产出思维链时),故用 Option 而非 String
#[serde(default)]
content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChatUsage {
#[serde(default)]
prompt_tokens: u32,
#[serde(default)]
completion_tokens: u32,
#[serde(default)]
total_tokens: u32,
}
#[derive(Debug, Deserialize)]
struct ApiErrorBody {
#[serde(default)]
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ModelsResponse {
#[serde(default)]
data: Vec<ModelEntry>,
}
#[derive(Debug, Deserialize)]
struct ModelEntry {
#[serde(default)]
id: String,
}