From de1f61dd5dff1731a58f8288f530e17aae99286f Mon Sep 17 00:00:00 2001 From: 0xvectr <57480598+0xvectr@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:17:45 +0800 Subject: [PATCH 1/2] A model card's Test button tests only that card Signed-off-by: 0xvectr <57480598+0xvectr@users.noreply.github.com> --- .../utopia-server/src/api/settings_routes.rs | 148 +++++++++++++----- web/src/api.ts | 9 +- web/src/pages/Settings.tsx | 130 ++++++++------- 3 files changed, 174 insertions(+), 113 deletions(-) diff --git a/crates/utopia-server/src/api/settings_routes.rs b/crates/utopia-server/src/api/settings_routes.rs index ff265d9ae..679af971c 100644 --- a/crates/utopia-server/src/api/settings_routes.rs +++ b/crates/utopia-server/src/api/settings_routes.rs @@ -1,8 +1,8 @@ -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::Json; use serde::Deserialize; use serde_json::json; -use utopia_core::models::Role; +use utopia_core::models::{LlmSettings, Role}; use utopia_llm::ChatMessage; use uuid::Uuid; @@ -322,59 +322,85 @@ fn check(r: &anyhow::Result<()>) -> serde_json::Value { } } +/// 设置页的四张模型卡,各有一个「测试」 +#[derive(Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ModelCard { + Chat, + Embed, + Ocr, + Transcribe, +} + +#[derive(Deserialize)] +pub struct TestQuery { + /// 只测这一张卡。缺席 = 四张都测 + pub card: Option, +} + /// 连通性测试:对话按任务的三种请求形状各试一次(见 [`chat_checks`]);embedding 试算一条并 /// 返回维度;版面识别服务问一声健康。 pub async fn test( State(state): State, AuthUser(user): AuthUser, Path(workspace_id): Path, + Query(q): Query, ) -> ApiResult> { utopia_store::workspaces::require_role(&state.pool, user.id, workspace_id, Role::Admin).await?; - let Some(s) = utopia_store::settings::get(&state.pool, workspace_id).await? else { - return Ok(Json( - json!({ "chat": { "ok": false, "error": "Not configured" }, - "embed": { "ok": false, "error": "Not configured" }, - "ocr": { "ok": false, "error": "Not configured" }, - "transcribe": { "ok": false, "error": "Not configured" } }), - )); - }; + let s = utopia_store::settings::get(&state.pool, workspace_id).await?; + Ok(Json(probe(s.as_ref(), q.card).await)) +} - let chat_result = match llm_util::chat_probe_client(&s) { - None => json!({ "ok": false, "error": "Not configured" }), - Some(client) => chat_checks(&client).await, - }; +/// 只测点了的那张卡(#1424)。从前四张卡共用一次测试,按任意一张四套一起测;自 #1363 起 +/// 对话一测要发三问,测嵌入、读扫描件的卡也跟着白花这几问的 token。回来的只有测了的那几项 +async fn probe(s: Option<&LlmSettings>, only: Option) -> serde_json::Value { + let wants = |card| only.is_none_or(|c| c == card); + let not_configured = || json!({ "ok": false, "error": "Not configured" }); + let mut out = serde_json::Map::new(); - let embed_result = match llm_util::embed_client(&s) { - None => json!({ "ok": false, "error": "Not configured" }), - Some(client) => match client.embed(&["connectivity test".to_string()]).await { - Ok(v) if !v.is_empty() => json!({ "ok": true, "dim": v[0].len() }), - Ok(_) => json!({ "ok": false, "error": "Empty response" }), - Err(e) => json!({ "ok": false, "error": e.to_string() }), - }, - }; + if wants(ModelCard::Chat) { + let chat_result = match s.and_then(llm_util::chat_probe_client) { + None => not_configured(), + Some(client) => chat_checks(&client).await, + }; + out.insert("chat".into(), chat_result); + } - let ocr_result = match crate::readers::Ocr::from_settings(&s) { - None => json!({ "ok": false, "error": "Not configured" }), - Some(ocr) => match ocr.health().await { - Ok(h) => json!({ "ok": true, "version": h["version"] }), - Err(e) => json!({ "ok": false, "error": format!("{e:#}") }), - }, - }; + if wants(ModelCard::Embed) { + let embed_result = match s.and_then(llm_util::embed_client) { + None => not_configured(), + Some(client) => match client.embed(&["connectivity test".to_string()]).await { + Ok(v) if !v.is_empty() => json!({ "ok": true, "dim": v[0].len() }), + Ok(_) => json!({ "ok": false, "error": "Empty response" }), + Err(e) => json!({ "ok": false, "error": e.to_string() }), + }, + }; + out.insert("embed".into(), embed_result); + } - let transcribe_result = match crate::readers::Transcriber::from_settings(&s) { - None => json!({ "ok": false, "error": "Not configured" }), - Some(t) => match t.check().await { - Ok(()) => json!({ "ok": true }), - Err(e) => json!({ "ok": false, "error": format!("{e:#}") }), - }, - }; + if wants(ModelCard::Ocr) { + let ocr_result = match s.and_then(crate::readers::Ocr::from_settings) { + None => not_configured(), + Some(ocr) => match ocr.health().await { + Ok(h) => json!({ "ok": true, "version": h["version"] }), + Err(e) => json!({ "ok": false, "error": format!("{e:#}") }), + }, + }; + out.insert("ocr".into(), ocr_result); + } - Ok(Json(json!({ - "chat": chat_result, - "embed": embed_result, - "ocr": ocr_result, - "transcribe": transcribe_result, - }))) + if wants(ModelCard::Transcribe) { + let transcribe_result = match s.and_then(crate::readers::Transcriber::from_settings) { + None => not_configured(), + Some(t) => match t.check().await { + Ok(()) => json!({ "ok": true }), + Err(e) => json!({ "ok": false, "error": format!("{e:#}") }), + }, + }; + out.insert("transcribe".into(), transcribe_result); + } + + out.into() } #[cfg(test)] @@ -514,4 +540,42 @@ mod tests { "the streamed and tool shapes are not sent when the plain one fails" ); } + + /// #1424:按嵌入卡的「测试」,对话模型一问都收不到;不指定卡的旧调用照旧四项都回 + #[tokio::test] + async fn testing_one_card_sends_nothing_to_the_others() { + let chat = endpoint(false).await; + let s = utopia_core::models::LlmSettings { + workspace_id: uuid::Uuid::nil(), + chat_base_url: Some(chat.uri()), + chat_api_key: None, + chat_model: Some("scripted".into()), + embed_base_url: None, + embed_api_key: None, + embed_model: None, + embed_dim: None, + updated_at: chrono::Utc::now(), + ocr_base_url: None, + ocr_api_key: None, + ocr_backend: None, + ocr_provider: "mineru".into(), + ocr_model: None, + transcribe_base_url: None, + transcribe_api_key: None, + transcribe_model: None, + chat_reasoning_effort: None, + }; + + let out = super::probe(Some(&s), Some(super::ModelCard::Embed)).await; + assert_eq!( + out, + serde_json::json!({ "embed": { "ok": false, "error": "Not configured" } }) + ); + assert!(bodies(&chat).await.is_empty()); + + let all = super::probe(Some(&s), None).await; + let cards: Vec<_> = all.as_object().unwrap().keys().collect(); + assert_eq!(cards, ["chat", "embed", "ocr", "transcribe"], "{all}"); + assert_eq!(bodies(&chat).await.len(), 3); + } } diff --git a/web/src/api.ts b/web/src/api.ts index cd21e34c7..be54834d8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2808,19 +2808,20 @@ export const api = { }, ), - testSettings: (workspaceId: string) => + /** 只测 `card` 那一张卡,回来的也只有那一项(#1424) */ + testSettings: (workspaceId: string, card: "chat" | "embed" | "ocr" | "transcribe") => request<{ /** `ok` only when all three request shapes the jobs send pass; `checks` has each one (#1354) */ - chat: { + chat?: { ok: boolean; reply?: string; error?: string; checks?: Record<"plain" | "streaming" | "tools", { ok: boolean; timed_out?: boolean; error?: string }>; }; - embed: { ok: boolean; dim?: number; error?: string }; + embed?: { ok: boolean; dim?: number; error?: string }; ocr?: { ok: boolean; version?: string | null; error?: string }; transcribe?: { ok: boolean; error?: string }; - }>(`/api/v1/workspaces/${workspaceId}/settings/test`, { method: "POST" }), + }>(`/api/v1/workspaces/${workspaceId}/settings/test?card=${card}`, { method: "POST" }), /** 读扫描件的服务、转写模型各自一个保存:存它们不碰对话与嵌入那几列。 * `requeued`:因为缺它而等着的文件,这一存重新排进了处理队列几份 */ saveOcrSettings: ( diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 466e2edce..6007a164b 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -38,10 +38,9 @@ import { SsoAdmin } from "./Sso"; * 聊天卡的报错却是危险色。于是"Saved" 说的是哪次操作、这次测的又是哪张卡, * 全得靠猜。 * - * 三条规矩,显示层只管最后一条,前两条在调用处用 reset 保证: + * 三条规矩,显示层只管最后一条,前两条在调用处保证: * 1. 改任何一格、按任何一次保存,旧结论(测试结果、Saved、报错)全部过期; - * 2. "Testing…" 只亮在按下的那张卡上(`testCard`),因为测的虽然是两套, - * 人按的是这一张; + * 2. 一张卡一个测试(#1424):测的、"Testing…"、测出的结论都只属于按下的那张卡; * 3. 新测到的结果(含测挂了)盖掉旧保存态——刚按了"测试",卡上就该说测试的事。 * * 传纯数据、回纯结论(含用哪个色调),字串仍在调用处配 i18n; @@ -1027,16 +1026,19 @@ export function Settings() { saveTranscribe.reset(); }; - const test = useMutation({ - mutationFn: () => api.testSettings(workspace!.id), - // 测完谁按的就清掉:pending 的字样只在飞行中属于那张卡 - onSettled: () => setTestCard(null), - }); - /* 哪张卡按下的"测试"。测一次是两套一起测(一个接口),结果各自回卡; - 但两个按钮共用这一个 mutation,从前按任意一张两张一起转"Testing…"(#698) */ - const [testCard, setTestCard] = useState<"chat" | "embed" | "ocr" | "transcribe" | null>( - null, - ); + /* 四张卡各测各的,一张一个 mutation(#1424):从前四张共用一次测试,按任意一张四套一起测, + 还得另记一个"是哪张按的"才能只让那一张转"Testing…"(#698)。分开以后测一张, + 别的卡上还作数的结论不受影响 */ + const useCardTest = (card: "chat" | "embed" | "ocr" | "transcribe") => + // eslint-disable-next-line react-hooks/rules-of-hooks + useMutation({ mutationFn: () => api.testSettings(workspace!.id, card) }); + const tests = { + chat: useCardTest("chat"), + embed: useCardTest("embed"), + ocr: useCardTest("ocr"), + transcribe: useCardTest("transcribe"), + }; + const resetTests = () => Object.values(tests).forEach((t) => t.reset()); /* 两张卡各自有没有改过、还没保存的格子。测试只测已保存的配置,所以有修改的那张卡 不让测,备注改说「先保存」;保存成功才清掉(#698) */ const [dirty, setDirty] = useState({ @@ -1045,11 +1047,12 @@ export function Settings() { ocr: false, transcribe: false, }); - /* 开测:上一轮的结论(两边卡的 Saved/报错、上一轮测试结果)全部让位给这一轮 */ - const startTest = () => { - test.reset(); - resetSaves(); - test.mutate(); + /* 开测:这张卡上一轮的结论(Saved/报错、上一轮测试结果)让位给这一轮;别的卡没测,不动 */ + const saves = { chat: saveChat, embed: saveEmbed, ocr: saveOcr, transcribe: saveTranscribe }; + const startTest = (card: keyof typeof tests) => { + tests[card].reset(); + saves[card].reset(); + tests[card].mutate(); }; if (!workspace) @@ -1059,7 +1062,7 @@ export function Settings() { (k: keyof typeof form) => (e: React.ChangeEvent) => { /* 改一格,旧结论全部过期:测试结果、Saved、报错说的都是改之前的那份配置(#698)。 测的永远是已存盘的那份,所以表单一动,卡上就不该再贴任何旧话 */ - test.reset(); + resetTests(); resetSaves(); // 字段名的前缀就是卡:chat_ / embed_ / ocr_ / transcribe_ setDirty((d) => ({ ...d, [k.split("_")[0]]: true })); @@ -1070,15 +1073,16 @@ export function Settings() { /* 两张卡的备注:新测到的盖掉旧保存态,测挂了(连接口都没通)也不再 悄悄退回"Saved"或空白——从前请求本身失败时卡上什么都不说(#698) */ - const testTransportError = test.error ? (test.error as Error).message : null; + const testTransportError = (card: keyof typeof tests) => + tests[card].error ? (tests[card].error as Error).message : null; const chatStatus = modelCardStatus( - test.data + tests.chat.data?.chat ? { - ok: test.data.chat.ok, - message: chatTestMessage(test.data.chat), + ok: tests.chat.data.chat.ok, + message: chatTestMessage(tests.chat.data.chat), } : null, - testTransportError, + testTransportError("chat"), { error: saveChat.error ? (saveChat.error as Error).message : null, saved: saveChat.isSuccess, @@ -1086,15 +1090,15 @@ export function Settings() { }, ); const embedStatus = modelCardStatus( - test.data + tests.embed.data?.embed ? { - ok: test.data.embed.ok, - message: test.data.embed.ok - ? S.settings.okDim(test.data.embed.dim ?? 0) - : (test.data.embed.error ?? ""), + ok: tests.embed.data.embed.ok, + message: tests.embed.data.embed.ok + ? S.settings.okDim(tests.embed.data.embed.dim ?? 0) + : (tests.embed.data.embed.error ?? ""), } : null, - testTransportError, + testTransportError("embed"), { error: saveEmbed.error ? (saveEmbed.error as Error).message : null, saved: saveEmbed.isSuccess, @@ -1102,15 +1106,15 @@ export function Settings() { }, ); const ocrStatus = modelCardStatus( - test.data?.ocr + tests.ocr.data?.ocr ? { - ok: test.data.ocr.ok, - message: test.data.ocr.ok - ? S.settings.okVersion(test.data.ocr.version ?? "?") - : (test.data.ocr.error ?? ""), + ok: tests.ocr.data.ocr.ok, + message: tests.ocr.data.ocr.ok + ? S.settings.okVersion(tests.ocr.data.ocr.version ?? "?") + : (tests.ocr.data.ocr.error ?? ""), } : null, - testTransportError, + testTransportError("ocr"), { error: saveOcr.error ? (saveOcr.error as Error).message : null, saved: saveOcr.isSuccess, @@ -1118,15 +1122,15 @@ export function Settings() { }, ); const transcribeStatus = modelCardStatus( - test.data?.transcribe + tests.transcribe.data?.transcribe ? { - ok: test.data.transcribe.ok, - message: test.data.transcribe.ok + ok: tests.transcribe.data.transcribe.ok, + message: tests.transcribe.data.transcribe.ok ? S.settings.okReachable - : (test.data.transcribe.error ?? ""), + : (tests.transcribe.data.transcribe.error ?? ""), } : null, - testTransportError, + testTransportError("transcribe"), { error: saveTranscribe.error ? (saveTranscribe.error as Error).message : null, saved: saveTranscribe.isSuccess, @@ -1176,7 +1180,7 @@ export function Settings() { key={name} onClick={() => { /* 预设只是批量填格,和手输一样让旧结论过期 */ - test.reset(); + resetTests(); resetSaves(); setDirty((d) => ({ ...d, chat: true, embed: true })); setForm({ @@ -1198,21 +1202,18 @@ export function Settings() { note={cardNote(chatStatus)} action={ <> - {/* 试一次连的是两套模型(一个接口),结果各自回到各自那张卡; - 但"Testing…"只亮在按下的这一张上(`testCard`) */}