//! 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 { 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 { Ok(format!("{}/chat/completions", self.api_root()?)) } fn models_endpoint(&self) -> Result { Ok(format!("{}/models", self.api_root()?)) } /// 密钥只从系统凭据管理器读,不进配置文件、不经前端。 fn api_key(&self) -> String { crate::translate::engine_api_key(&self.cfg.id) } fn require_key(&self) -> Result { 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 { 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, request_id: String, content: String, req: &EngineRequest, started: Instant, finish_reason: Option, ) -> 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 { 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, ) -> 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 = Vec::new(); let mut content = String::new(); let mut finish_reason: Option = 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 = 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::(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, 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 = 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 { 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, 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, // 流式路径不索取 usage:stream_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, #[serde(default)] usage: Option, #[serde(default)] error: Option, } #[derive(Debug, Deserialize)] struct ChatChoice { #[serde(default)] message: ChatMessage, } #[derive(Debug, Default, Deserialize)] struct ChatMessage { /// 部分实现会返回 null(例如只产出思维链时),故用 Option 而非 String #[serde(default)] content: Option, } #[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, } #[derive(Debug, Deserialize)] struct ModelsResponse { #[serde(default)] data: Vec, } #[derive(Debug, Deserialize)] struct ModelEntry { #[serde(default)] id: String, }