diff --git a/crates/utopia-server/src/api/settings_routes.rs b/crates/utopia-server/src/api/settings_routes.rs index ff265d9ae..c1ca3834f 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,86 @@ 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 试算一条并 -/// 返回维度;版面识别服务问一声健康。 +/// 返回维度;版面识别服务问一声健康。带 `?card=`([`ModelCard`])只测那一张卡,回来的也只有 +/// 那一项;不带就四张都测(#1424)。 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 +541,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.test.ts b/web/src/pages/Settings.test.ts index 0e73b4fbc..4f0949052 100644 --- a/web/src/pages/Settings.test.ts +++ b/web/src/pages/Settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { S } from "../i18n"; -import { chatTestMessage, modelCardStatus } from "./Settings"; +import { cardsOf, chatTestMessage, modelCardStatus } from "./Settings"; describe("model card status", () => { it("prefers a fresh test result over a stale save confirmation", () => { @@ -102,3 +102,18 @@ describe("chat test message", () => { expect(chatTestMessage({ ok: false, error: "Not configured" })).toBe("Not configured"); }); }); + +describe("cards an edit touches", () => { + it("is only the card whose field changed", () => { + // #1424:改读扫描件那张卡,对话卡刚测出的结论还作数 + expect(cardsOf(["ocr_base_url"])).toEqual(["ocr"]); + expect(cardsOf(["transcribe_api_key"])).toEqual(["transcribe"]); + }); + + it("is chat and embed for a preset", () => { + expect(cardsOf(["chat_base_url", "chat_model", "embed_base_url", "embed_model"])).toEqual([ + "chat", + "embed", + ]); + }); +}); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 466e2edce..d2e39e4ca 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -38,10 +38,10 @@ import { SsoAdmin } from "./Sso"; * 聊天卡的报错却是危险色。于是"Saved" 说的是哪次操作、这次测的又是哪张卡, * 全得靠猜。 * - * 三条规矩,显示层只管最后一条,前两条在调用处用 reset 保证: - * 1. 改任何一格、按任何一次保存,旧结论(测试结果、Saved、报错)全部过期; - * 2. "Testing…" 只亮在按下的那张卡上(`testCard`),因为测的虽然是两套, - * 人按的是这一张; + * 三条规矩,显示层只管最后一条,前两条在调用处保证: + * 1. 改任何一格、按任何一次保存,旧的 Saved、报错全部过期;测试结果只过期改了的那张卡的 + * (见 `cardsOf`),别的卡配置没动,测出的结论还作数(#1424); + * 2. 一张卡一个测试(#1424):测的、"Testing…"、测出的结论都只属于按下的那张卡; * 3. 新测到的结果(含测挂了)盖掉旧保存态——刚按了"测试",卡上就该说测试的事。 * * 传纯数据、回纯结论(含用哪个色调),字串仍在调用处配 i18n; @@ -55,6 +55,15 @@ export type ModelCardStatus = | { kind: "saved" } | { kind: "idle" }; +export type ModelCard = "chat" | "embed" | "ocr" | "transcribe"; +/** 改了这几格,是哪几张卡的配置变了。字段名的前缀就是卡:chat_ / embed_ / ocr_ / transcribe_; + * 预设一次填对话与嵌入两张卡的格,两张都算(#1424) */ +export function cardsOf(fields: readonly string[]): ModelCard[] { + return (["chat", "embed", "ocr", "transcribe"] as const).filter((c) => + fields.some((f) => f.startsWith(`${c}_`)), + ); +} + /** 对话卡的测试结论:三种请求形状各一行,被拒的那一行带上端点的原话,于是一眼看得出 * 是哪个参数不收(#1354)。旧服务端不回 `checks` 时退回一整句 */ export function chatTestMessage(chat: { @@ -1027,16 +1036,21 @@ 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: ModelCard) => + // 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 expireTests = (fields: readonly string[]) => + cardsOf(fields).forEach((c) => tests[c].reset()); /* 两张卡各自有没有改过、还没保存的格子。测试只测已保存的配置,所以有修改的那张卡 不让测,备注改说「先保存」;保存成功才清掉(#698) */ const [dirty, setDirty] = useState({ @@ -1045,11 +1059,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) @@ -1057,9 +1072,9 @@ export function Settings() { const set = (k: keyof typeof form) => (e: React.ChangeEvent) => { - /* 改一格,旧结论全部过期:测试结果、Saved、报错说的都是改之前的那份配置(#698)。 - 测的永远是已存盘的那份,所以表单一动,卡上就不该再贴任何旧话 */ - test.reset(); + /* 改一格,旧结论过期:Saved、报错说的都是改之前的那份配置(#698)。测的永远是 + 已存盘的那份,这张卡的测试结果也一起过期;别的卡没动,测出的还作数(#1424) */ + expireTests([k]); resetSaves(); // 字段名的前缀就是卡:chat_ / embed_ / ocr_ / transcribe_ setDirty((d) => ({ ...d, [k.split("_")[0]]: true })); @@ -1070,15 +1085,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 +1102,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 +1118,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 +1134,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,16 +1192,16 @@ export function Settings() { key={name} onClick={() => { /* 预设只是批量填格,和手输一样让旧结论过期 */ - test.reset(); - resetSaves(); - setDirty((d) => ({ ...d, chat: true, embed: true })); - setForm({ - ...form, + const filled = { chat_base_url: p.chat, chat_model: p.chatModel, embed_base_url: p.embed, embed_model: p.embedModel, - }); + }; + expireTests(Object.keys(filled)); + resetSaves(); + setDirty((d) => ({ ...d, chat: true, embed: true })); + setForm({ ...form, ...filled }); }} > {name} @@ -1198,21 +1214,18 @@ export function Settings() { note={cardNote(chatStatus)} action={ <> - {/* 试一次连的是两套模型(一个接口),结果各自回到各自那张卡; - 但"Testing…"只亮在按下的这一张上(`testCard`) */}