Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions client/src/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -418,8 +430,18 @@ export async function getModelRoutes() {
return data;
}

export async function testModelRouteConnectivity() {
const { data } = await apiClient.post<ApiResponse<ModelRouteConnectivityResponse>>("/llm/model-routes/connectivity");
export async function testModelRouteConnectivity(force = false) {
const { data } = await apiClient.post<ApiResponse<ModelRouteConnectivityCheckResponse>>(
"/llm/model-routes/connectivity",
{ force },
);
return data;
}

export async function getModelRouteConnectivityStatus() {
const { data } = await apiClient.get<ApiResponse<ModelRouteConnectivityCheckResponse>>(
"/llm/model-routes/connectivity/status",
);
return data;
}

Expand Down
48 changes: 48 additions & 0 deletions client/src/hooks/useModelRouteCheck.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
48 changes: 21 additions & 27 deletions client/src/pages/settings/ModelRoutesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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();
},
});

Expand All @@ -92,27 +90,23 @@ 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();
},
});

const saveStructuredFallbackMutation = useMutation({
mutationFn: (payload: Partial<StructuredFallbackSettings>) => 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]);
Expand Down Expand Up @@ -265,15 +259,15 @@ export default function ModelRoutesPage() {
<div className="flex flex-wrap items-center gap-3 text-xs">
<span className="inline-flex items-center gap-2">
<RouteStatusDot
state={modelRouteConnectivityQuery.isPending || modelRouteConnectivityQuery.isFetching
state={isChecking
? "checking"
: connectivitySummary.failed > 0
? "failed"
: connectivitySummary.total > 0
? "healthy"
: "idle"}
/>
{modelRouteConnectivityQuery.isPending || modelRouteConnectivityQuery.isFetching
{isChecking
? "正在检测生效路由..."
: connectivitySummary.total > 0
? `检测结果:${connectivitySummary.total} 条路由,健康 ${connectivitySummary.healthy},异常 ${connectivitySummary.failed}`
Expand All @@ -287,11 +281,11 @@ export default function ModelRoutesPage() {
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => void modelRouteConnectivityQuery.refetch()}
disabled={modelRouteConnectivityQuery.isFetching || !modelRoutesQuery.isSuccess}
onClick={() => triggerCheck.mutate(true)}
disabled={triggerCheck.isPending || !modelRoutesQuery.isSuccess}
>
<RefreshCw className={`h-4 w-4 ${modelRouteConnectivityQuery.isFetching ? "animate-spin" : ""}`} />
{modelRouteConnectivityQuery.isFetching ? "检测中..." : "重新检测"}
<RefreshCw className={`h-4 w-4 ${isChecking ? "animate-spin" : ""}`} />
{isChecking ? "检测中..." : "重新检测"}
</Button>
<Button asChild variant="outline">
<Link to="/settings">
Expand Down Expand Up @@ -434,7 +428,7 @@ export default function ModelRoutesPage() {
const connectivity = connectivityMap.get(taskType);
const connectivityState = resolveConnectivityState(
connectivity,
modelRouteConnectivityQuery.isPending || modelRouteConnectivityQuery.isFetching,
isChecking,
);
const isDirty = dirtyTaskTypeSet.has(taskType);
const hasUnsavedRouteDiff = connectivity != null
Expand Down
26 changes: 20 additions & 6 deletions client/src/pages/settings/components/SettingsReadinessCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type SettingsReadinessItem = {
key: "model" | "routes" | "rag" | "style";
title: string;
description: string;
state: "ready" | "warning" | "optional" | "checking";
state: "ready" | "warning" | "optional" | "checking" | "idle";
};

function getReadinessIcon(state: SettingsReadinessItem["state"]) {
Expand All @@ -26,7 +26,7 @@ function getReadinessIcon(state: SettingsReadinessItem["state"]) {
if (state === "checking") {
return <Loader2 className="h-4 w-4 animate-spin text-amber-600" />;
}
if (state === "optional") {
if (state === "optional" || state === "idle") {
return <CircleDashed className="h-4 w-4 text-sky-600" />;
}
return <CircleAlert className="h-4 w-4 text-amber-600" />;
Expand All @@ -40,6 +40,8 @@ function getReadinessBadge(state: SettingsReadinessItem["state"]) {
return "检查中";
case "optional":
return "可选增强";
case "idle":
return "未检测";
case "warning":
return "需要处理";
}
Expand Down Expand Up @@ -88,12 +90,24 @@ export function buildSettingsReadinessItems(input: {
{
key: "routes",
title: "模型路由",
state: isModelRoutesChecking ? "checking" : hasRoutes && failedRouteCount === 0 ? "ready" : "warning",
state: isModelRoutesChecking
? "checking"
: !hasRoutes
? "warning"
: modelRouteConnectivity == null
? "idle"
: failedRouteCount === 0
? "ready"
: "warning",
description: isModelRoutesChecking
? "正在检查开书、拆章、正文生成和审核任务的模型兼容性。"
: hasRoutes && failedRouteCount === 0
? "创作任务已有可用路由,后续流程会按任务选择模型。"
: "部分创作任务还需要补齐或修复模型路由。",
: !hasRoutes
? "尚未配置任何任务路由,进入模型与厂商页面可补齐。"
: modelRouteConnectivity == null
? "尚未检测模型兼容性,进入模型与厂商页面可执行检测。"
: failedRouteCount === 0
? "创作任务已有可用路由,后续流程会按任务选择模型。"
: "部分创作任务还需要补齐或修复模型路由。",
},
{
key: "rag",
Expand Down
15 changes: 5 additions & 10 deletions client/src/pages/settings/views/SettingsOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
getModelRoutes,
getRagSettings,
getStyleEngineRuntimeSettings,
testModelRouteConnectivity,
} from "@/api/settings";
import { useModelRouteCheck } from "@/hooks/useModelRouteCheck";
import { queryKeys } from "@/api/queryKeys";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
Expand All @@ -26,23 +26,18 @@ const entries = [
export default function SettingsOverviewPage() {
const providersQuery = useQuery({ queryKey: queryKeys.settings.apiKeys, queryFn: getAPIKeySettings });
const routesQuery = useQuery({ queryKey: queryKeys.settings.modelRoutes, queryFn: getModelRoutes });
const connectivityQuery = useQuery({
queryKey: queryKeys.settings.modelRouteConnectivity,
queryFn: testModelRouteConnectivity,
enabled: routesQuery.isSuccess,
refetchOnWindowFocus: false,
});
const { status: connectivityCheck, isChecking } = useModelRouteCheck();
const ragQuery = useQuery({ queryKey: queryKeys.settings.rag, queryFn: getRagSettings });
const styleQuery = useQuery({ queryKey: queryKeys.settings.styleEngineRuntime, queryFn: getStyleEngineRuntimeSettings });
const items = useMemo(() => buildSettingsReadinessItems({
providers: providersQuery.data?.data ?? [],
modelRoutes: routesQuery.data?.data,
modelRouteConnectivity: connectivityQuery.data?.data,
modelRouteConnectivity: connectivityCheck?.result ?? null,
ragSettings: ragQuery.data?.data,
styleSettings: styleQuery.data?.data,
isModelRoutesChecking: connectivityQuery.isPending || connectivityQuery.isFetching,
isModelRoutesChecking: isChecking,
isStyleSettingsLoaded: styleQuery.isSuccess,
}), [connectivityQuery.data?.data, connectivityQuery.isFetching, connectivityQuery.isPending, providersQuery.data?.data, ragQuery.data?.data, routesQuery.data?.data, styleQuery.data?.data, styleQuery.isSuccess]);
}), [connectivityCheck?.result, isChecking, providersQuery.data?.data, ragQuery.data?.data, routesQuery.data?.data, styleQuery.data?.data, styleQuery.isSuccess]);
const configuredProvider = providersQuery.data?.data?.find((item) => item.isConfigured && item.isActive);
const routeCount = routesQuery.data?.data?.routes.filter((route) => route.provider && route.model).length ?? 0;
const rag = ragQuery.data?.data;
Expand Down
Loading