From 4adb50c5a087f7bfc2882a275cafa1894dadb212 Mon Sep 17 00:00:00 2001 From: hllshiro Date: Sun, 16 Aug 2026 18:34:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(settings):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=BF=9E=E9=80=9A=E6=80=A7=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=88?= =?UTF-8?q?single-flight=20+=20=E6=8C=87=E7=BA=B9=E7=BC=93=E5=AD=98?= =?UTF-8?q?=EF=BC=89=20=20-=20=E5=90=8E=E7=AB=AF=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20checkController=20=E7=BB=9F=E4=B8=80=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=EF=BC=88idle/running/done=EF=BC=89=EF=BC=8Cs?= =?UTF-8?q?ingle-flight=20=E5=B9=B6=E5=8F=91=E5=8E=BB=E9=87=8D=EF=BC=8C=20?= =?UTF-8?q?=20=20=E9=85=8D=E7=BD=AE=E6=8C=87=E7=BA=B9=E7=BC=93=E5=AD=98?= =?UTF-8?q?=EF=BC=88force=20=E5=8F=AF=E7=BB=95=E8=BF=87=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=85=A8=E5=A4=B1=E8=B4=A5=E4=B8=8D=E6=9B=B4=E6=96=B0=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=20-=20connectivity.ts=EF=BC=9A=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20runModelRoutesProbe=EF=BC=8CtestModelRoute?= =?UTF-8?q?s=20=E4=BF=9D=E7=95=99=E4=B8=BA=E5=85=BC=E5=AE=B9=E8=96=84?= =?UTF-8?q?=E5=B0=81=E8=A3=85=20-=20=E8=B7=AF=E7=94=B1=EF=BC=9APOST=20/mod?= =?UTF-8?q?el-routes/connectivity=20=E6=8E=A5=E5=8F=97=20{=20force=20}?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=20GET=20.../status=20-=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=9AuseModelRouteCheck=20=E7=BB=9F=E4=B8=80=20Hoo?= =?UTF-8?q?k=EF=BC=88=E8=BF=9B=E5=85=A5=E9=A1=B5=E9=9D=A2=E9=9B=B6?= =?UTF-8?q?=E6=8E=A2=E9=92=88=EF=BC=8Crunning=20=E8=BD=AE=E8=AF=A2=202s?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=20=20=20ModelRoutesPage=20/=20SettingsOvervi?= =?UTF-8?q?ewPage=20=E6=8E=A5=E5=85=A5=EF=BC=9BReadinessCard=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=9C=AA=E6=A3=80=E6=B5=8B=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/settings.ts | 26 +++- client/src/hooks/useModelRouteCheck.ts | 48 +++++++ client/src/pages/settings/ModelRoutesPage.tsx | 48 +++---- .../components/SettingsReadinessCard.tsx | 26 +++- .../settings/views/SettingsOverviewPage.tsx | 15 +- server/src/llm/checkController.ts | 132 ++++++++++++++++++ server/src/llm/connectivity.ts | 35 +++-- server/src/routes/llm.ts | 21 ++- 8 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 client/src/hooks/useModelRouteCheck.ts create mode 100644 server/src/llm/checkController.ts diff --git a/client/src/api/settings.ts b/client/src/api/settings.ts index 13d98eaa42..c2ac163707 100644 --- a/client/src/api/settings.ts +++ b/client/src/api/settings.ts @@ -169,6 +169,18 @@ export interface ModelRouteConnectivityResponse { statuses: ModelRouteConnectivityStatus[]; } +export type ModelRouteConnectivityCheckStatus = "idle" | "running" | "done"; + +export interface ModelRouteConnectivityCheckResponse { + status: ModelRouteConnectivityCheckStatus; + taskId: string | null; + startedAt: string | null; + completedAt: string | null; + fingerprint: string | null; + result: ModelRouteConnectivityResponse | null; + cached?: boolean; +} + export interface StructuredFallbackSettings { enabled: boolean; provider: LLMProvider; @@ -418,8 +430,18 @@ export async function getModelRoutes() { return data; } -export async function testModelRouteConnectivity() { - const { data } = await apiClient.post>("/llm/model-routes/connectivity"); +export async function testModelRouteConnectivity(force = false) { + const { data } = await apiClient.post>( + "/llm/model-routes/connectivity", + { force }, + ); + return data; +} + +export async function getModelRouteConnectivityStatus() { + const { data } = await apiClient.get>( + "/llm/model-routes/connectivity/status", + ); return data; } diff --git a/client/src/hooks/useModelRouteCheck.ts b/client/src/hooks/useModelRouteCheck.ts new file mode 100644 index 0000000000..e38095bcd8 --- /dev/null +++ b/client/src/hooks/useModelRouteCheck.ts @@ -0,0 +1,48 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getModelRouteConnectivityStatus, testModelRouteConnectivity } from "@/api/settings"; +import { queryKeys } from "@/api/queryKeys"; + +/** + * 模型路由连通性检查统一 Hook。 + * + * - 所有设置页面共用同一状态源(后端 single-flight 状态机 + 指纹缓存) + * - 进入页面只查 GET /status(轻量),绝不自动触发探针 + * - running 时自动轮询(2s),done/idle 停止 + * - triggerCheck(force):手动刷新;checkAfterConfigChange():保存路由后自动检查 + */ +export function useModelRouteCheck() { + const queryClient = useQueryClient(); + const checkKey = queryKeys.settings.modelRouteConnectivity; + + const statusQuery = useQuery({ + queryKey: checkKey, + queryFn: getModelRouteConnectivityStatus, + refetchInterval: (query) => (query.state.data?.data?.status === "running" ? 2000 : false), + refetchOnWindowFocus: false, + }); + + const triggerCheck = useMutation({ + mutationFn: (force: boolean) => testModelRouteConnectivity(force), + onSuccess: (resp) => { + // 统一状态写入,跨页面共享 + queryClient.setQueryData(checkKey, resp); + if (resp.data?.status === "running") { + void statusQuery.refetch(); + } + }, + }); + + return { + /** 当前检查状态(idle/running/done + result),来自 GET /status */ + status: statusQuery.data?.data, + /** 是否正在检查(POST 进行中 或 后端 running) */ + isChecking: triggerCheck.isPending || statusQuery.data?.data?.status === "running", + /** 触发检查:true 强制绕过指纹缓存(仍受 single-flight 约束) */ + triggerCheck, + /** 配置变更后自动触发新检查(指纹变化 → 后端启动新任务) */ + checkAfterConfigChange: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRoutes }); + triggerCheck.mutate(false); + }, + }; +} diff --git a/client/src/pages/settings/ModelRoutesPage.tsx b/client/src/pages/settings/ModelRoutesPage.tsx index 444239cfb8..82e786ab4a 100644 --- a/client/src/pages/settings/ModelRoutesPage.tsx +++ b/client/src/pages/settings/ModelRoutesPage.tsx @@ -9,8 +9,8 @@ import { getStructuredFallbackConfig, saveModelRoute, saveStructuredFallbackConfig, - testModelRouteConnectivity, } from "@/api/settings"; +import { useModelRouteCheck } from "@/hooks/useModelRouteCheck"; import { queryKeys } from "@/api/queryKeys"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -61,12 +61,12 @@ export default function ModelRoutesPage() { queryFn: getModelRoutes, }); - const modelRouteConnectivityQuery = useQuery({ - queryKey: queryKeys.settings.modelRouteConnectivity, - queryFn: testModelRouteConnectivity, - enabled: modelRoutesQuery.isSuccess, - refetchOnWindowFocus: false, - }); + const { + status: connectivityCheck, + isChecking, + triggerCheck, + checkAfterConfigChange, + } = useModelRouteCheck(); const structuredFallbackQuery = useQuery({ queryKey: queryKeys.settings.structuredFallback, @@ -78,10 +78,8 @@ export default function ModelRoutesPage() { mutationFn: (payload: RouteSavePayload) => saveModelRoute(payload), onSuccess: async () => { setActionResult("保存完成,这个任务会使用新路由。"); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRoutes }), - queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRouteConnectivity }), - ]); + await queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRoutes }); + checkAfterConfigChange(); }, }); @@ -92,10 +90,8 @@ export default function ModelRoutesPage() { }, onSuccess: async (count) => { setActionResult(`保存完成,${count} 个任务会使用新路由。`); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRoutes }), - queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRouteConnectivity }), - ]); + await queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRoutes }); + checkAfterConfigChange(); }, }); @@ -103,16 +99,14 @@ export default function ModelRoutesPage() { mutationFn: (payload: Partial) => saveStructuredFallbackConfig(payload), onSuccess: async () => { setActionResult("结构化备用模型保存完成。"); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.settings.structuredFallback }), - queryClient.invalidateQueries({ queryKey: queryKeys.settings.modelRouteConnectivity }), - ]); + await queryClient.invalidateQueries({ queryKey: queryKeys.settings.structuredFallback }); + checkAfterConfigChange(); }, }); const providerConfigs = useMemo(() => apiKeySettingsQuery.data?.data ?? [], [apiKeySettingsQuery.data?.data]); const modelRoutes = modelRoutesQuery.data?.data; - const modelRouteConnectivity = modelRouteConnectivityQuery.data?.data; + const modelRouteConnectivity = connectivityCheck?.result; const structuredFallback = structuredFallbackQuery.data?.data; const taskTypes = modelRoutes?.taskTypes ?? []; const providerOptions = useMemo(() => providerConfigs.map((item) => item.provider), [providerConfigs]); @@ -265,7 +259,7 @@ export default function ModelRoutesPage() {
0 ? "failed" @@ -273,7 +267,7 @@ export default function ModelRoutesPage() { ? "healthy" : "idle"} /> - {modelRouteConnectivityQuery.isPending || modelRouteConnectivityQuery.isFetching + {isChecking ? "正在检测生效路由..." : connectivitySummary.total > 0 ? `检测结果:${connectivitySummary.total} 条路由,健康 ${connectivitySummary.healthy},异常 ${connectivitySummary.failed}` @@ -287,11 +281,11 @@ export default function ModelRoutesPage() {