翻译模块
This commit is contained in:
@@ -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,
|
||||
// 流式路径不索取 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<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,
|
||||
}
|
||||
Reference in New Issue
Block a user