Files
Thing/src-tauri/src/translate/engines/ai.rs
T
2026-09-17 17:32:20 +08:00

594 lines
21 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.
//! 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,
}