feat(sites): per-site model probe with streaming logs and auto-disable

- Move post-refresh probe settings from global config to per-site (sites table)
- Add manual 'probe now' button in site editor with real-time SSE log stream
- Use fetch+ReadableStream instead of EventSource to support Authorization header
- Concurrent probe execution (default 10 workers) via worker-pool pattern
- Add stop button to cancel in-flight probe while preserving completed results
- Add latency threshold: auto-disable models exceeding response time limit
- Treat inconclusive probe results as unsupported (auto-disable)
- Show specific failure reason in logs (timeout, no token, model not found, etc.)
- Refresh and display post-probe model status list (green=available, red=disabled)
- Remove probe settings from global Settings page and RuntimeSettingsPayload
This commit is contained in:
Xiang Li
2026-04-23 21:42:48 +08:00
parent 262651a708
commit 46f23ddbb3
7 changed files with 669 additions and 0 deletions
+3
View File
@@ -15,6 +15,9 @@ export const sites = sqliteTable('sites', {
sortOrder: integer('sort_order').default(0),
globalWeight: real('global_weight').default(1),
apiKey: text('api_key'),
postRefreshProbeEnabled: integer('post_refresh_probe_enabled', { mode: 'boolean' }).default(false),
postRefreshProbeModel: text('post_refresh_probe_model').default(''),
postRefreshProbeScope: text('post_refresh_probe_scope').default('single'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({
+4
View File
@@ -702,6 +702,10 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.tokenRouterFailureCooldownMaxSec = normalized;
return;
}
case 'post_refresh_probe_enabled':
case 'post_refresh_probe_model':
case 'post_refresh_probe_scope':
return;
default:
return;
}
+61
View File
@@ -18,6 +18,11 @@ import {
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
import { analyzePrimarySiteUrl } from '../../../shared/sitePrimaryUrl.js';
import { probeSiteModels } from '../../services/modelService.js';
function sseWrite(raw: import('http').ServerResponse, event: string, data: unknown) {
try { raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* ignore */ }
}
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -683,6 +688,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
const anyBody = body as Record<string, unknown>;
if (anyBody.postRefreshProbeEnabled !== undefined) updates.postRefreshProbeEnabled = Boolean(anyBody.postRefreshProbeEnabled);
if (anyBody.postRefreshProbeModel !== undefined) updates.postRefreshProbeModel = String(anyBody.postRefreshProbeModel || '').trim();
if (anyBody.postRefreshProbeScope !== undefined) updates.postRefreshProbeScope = anyBody.postRefreshProbeScope === 'all' ? 'all' : 'single';
updates.updatedAt = new Date().toISOString();
try {
await db.transaction(async (tx) => {
@@ -887,6 +896,58 @@ export async function sitesRoutes(app: FastifyInstance) {
return { siteId: id, models };
});
// Manually probe site models now (one-shot JSON)
app.post<{ Params: { id: string }; Body: unknown }>('/api/sites/:id/probe-now', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const body = request.body as Record<string, unknown> | null;
const scope = body?.scope === 'all' ? 'all' : body?.scope === 'single' ? 'single' : undefined;
const modelName = typeof body?.modelName === 'string' ? body.modelName.trim() : undefined;
const result = await probeSiteModels(id, { scope, modelName });
if (!result.success) {
return reply.code(422).send({ error: result.error });
}
return result;
});
// Streaming probe via SSE
app.get<{ Params: { id: string }; Querystring: { scope?: string; modelName?: string; latencyThresholdMs?: string } }>(
'/api/sites/:id/probe-stream',
async (request, reply) => {
reply.hijack();
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
sseWrite(reply.raw, 'error', { message: 'Invalid site id' });
reply.raw.end();
return;
}
const q = request.query;
const scope = q.scope === 'all' ? 'all' : q.scope === 'single' ? 'single' : undefined;
const modelName = q.modelName?.trim() || undefined;
const parsedThreshold = parseInt(q.latencyThresholdMs ?? '', 10);
const latencyThresholdMs = Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : undefined;
try {
const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs }, (ev) => {
sseWrite(reply.raw, ev.type, ev);
});
sseWrite(reply.raw, 'complete', result);
} catch (err: any) {
sseWrite(reply.raw, 'error', { message: err?.message || '探测失败' });
}
reply.raw.end();
},
);
// Detect platform for a URL
app.post<{ Body: unknown }>('/api/sites/detect', async (request, reply) => {
const parsedBody = parseSiteDetectPayload(request.body);
+6
View File
@@ -758,6 +758,9 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): {
sortOrder: section.sites.length,
globalWeight: 1,
apiKey: null,
postRefreshProbeEnabled: false,
postRefreshProbeModel: '',
postRefreshProbeScope: 'single',
createdAt: input.createdAt,
updatedAt: input.updatedAt,
});
@@ -1001,6 +1004,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
sortOrder: sites.length,
globalWeight: 1,
apiKey: null,
postRefreshProbeEnabled: false,
postRefreshProbeModel: '',
postRefreshProbeScope: 'single',
createdAt: toIsoString(item.created_at),
updatedAt: toIsoString(item.updated_at),
});
+252
View File
@@ -34,6 +34,7 @@ import {
discoverCodexModelsFromCloud,
validateGeminiCliOauthConnection,
} from './platformDiscoveryRegistry.js';
import { probeRuntimeModel, type RuntimeModelProbeStatus } from './runtimeModelProbe.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
@@ -101,6 +102,16 @@ export type ModelRefreshSuccessResult = {
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
postProbeResult?: {
scope: 'single' | 'all';
probed: number;
unsupported: number;
details: Array<{
modelName: string;
status: RuntimeModelProbeStatus;
latencyMs: number | null;
}>;
};
};
export type ModelRefreshResult =
@@ -310,6 +321,7 @@ function buildSuccessfulRefreshResult(input: {
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
postProbeResult?: ModelRefreshSuccessResult['postProbeResult'];
}): ModelRefreshSuccessResult {
return {
accountId: input.accountId,
@@ -322,6 +334,7 @@ function buildSuccessfulRefreshResult(input: {
tokenScanned: input.tokenScanned,
discoveredByCredential: input.discoveredByCredential,
discoveredApiToken: input.discoveredApiToken,
postProbeResult: input.postProbeResult,
};
}
@@ -368,6 +381,215 @@ async function retryOauthModelDiscoveryWithRefresh<T>(input: {
}
}
export type ProbeSiteModelsResult = {
success: boolean;
error?: string;
scope: 'single' | 'all';
probed: number;
unsupported: number;
details: Array<{ modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null; reason?: string }>;
};
export type ProbeSiteModelsProgress =
| { type: 'start'; scope: 'single' | 'all'; modelsCount: number; modelsToProbe: string[] }
| { type: 'model'; modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null; latencyExceeded?: true; reason?: string }
| { type: 'action'; modelName: string; action: 'disabled' };
export async function probeSiteModels(
siteId: number,
options?: { scope?: 'single' | 'all'; modelName?: string; concurrency?: number; latencyThresholdMs?: number },
onProgress?: (event: ProbeSiteModelsProgress) => void,
): Promise<ProbeSiteModelsResult> {
const empty = (scope: 'single' | 'all', error: string): ProbeSiteModelsResult =>
({ success: false, error, scope, probed: 0, unsupported: 0, details: [] });
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return empty('single', '站点不存在');
const account = await db.select().from(schema.accounts)
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.status, 'active')))
.get();
if (!account) return empty('single', '该站点没有可用的活跃账号');
const modelRows = await db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.where(and(
eq(schema.modelAvailability.accountId, account.id),
eq(schema.modelAvailability.available, true),
))
.all();
const scope = (options?.scope ?? (site.postRefreshProbeScope === 'all' ? 'all' : 'single')) as 'single' | 'all';
const availableModels = modelRows.map((r) => r.modelName.trim()).filter((m) => m.length > 0);
if (availableModels.length === 0) {
return empty(scope, '该站点暂无已发现模型,请先刷新模型列表');
}
let modelsToProbe: string[];
if (scope === 'all') {
modelsToProbe = availableModels;
} else {
const configModel = ((options?.modelName ?? site.postRefreshProbeModel) || '').trim().toLowerCase();
const found = configModel
? (availableModels.find((m) => m.toLowerCase() === configModel) ?? availableModels[0])
: availableModels[0];
modelsToProbe = [found];
}
onProgress?.({ type: 'start', scope, modelsCount: modelsToProbe.length, modelsToProbe });
// Probe models concurrently, limited by modelAvailabilityProbeConcurrency
const concurrency = Math.max(1, options?.concurrency ?? 10);
const detailsMap = new Map<string, { modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }>();
let cursor = 0;
async function worker() {
while (cursor < modelsToProbe.length) {
const modelName = modelsToProbe[cursor++];
try {
const result = await probeRuntimeModel({
site, account, modelName, timeoutMs: config.modelAvailabilityProbeTimeoutMs,
});
const threshold = options?.latencyThresholdMs ?? 0;
const latencyExceeded = (
result.status === 'supported'
&& threshold > 0
&& result.latencyMs != null
&& result.latencyMs > threshold
);
const effectiveStatus: RuntimeModelProbeStatus = latencyExceeded ? 'unsupported' : result.status;
const effectiveReason = latencyExceeded
? `响应延迟 ${result.latencyMs}ms 超过阈值 ${threshold}ms`
: result.reason;
detailsMap.set(modelName, { modelName, status: effectiveStatus, latencyMs: result.latencyMs, reason: effectiveReason });
onProgress?.(latencyExceeded
? { type: 'model', modelName, status: effectiveStatus, latencyMs: result.latencyMs, latencyExceeded: true, reason: effectiveReason }
: { type: 'model', modelName, status: effectiveStatus, latencyMs: result.latencyMs, reason: effectiveReason },
);
} catch (err) {
const errReason = err instanceof Error ? err.message : '探测异常';
console.warn(`[probe-site-now] probe failed for site ${siteId} model ${modelName}`, err);
detailsMap.set(modelName, { modelName, status: 'inconclusive', latencyMs: null, reason: errReason });
onProgress?.({ type: 'model', modelName, status: 'inconclusive', latencyMs: null, reason: errReason });
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, modelsToProbe.length) }, worker));
// Restore original model order for the final details list
const details = modelsToProbe.map((m) => detailsMap.get(m)!);
const unsupportedModels = details.filter((d) => d.status === 'unsupported' || d.status === 'inconclusive').map((d) => d.modelName);
if (unsupportedModels.length > 0) {
const checkedAt = new Date().toISOString();
for (const modelName of unsupportedModels) {
await db.update(schema.modelAvailability)
.set({ available: false, checkedAt })
.where(and(
eq(schema.modelAvailability.accountId, account.id),
eq(schema.modelAvailability.modelName, modelName),
))
.run();
await db.insert(schema.siteDisabledModels)
.values({ siteId, modelName })
.onConflictDoNothing()
.run();
onProgress?.({ type: 'action', modelName, action: 'disabled' });
}
const reason = unsupportedModels.length === 1
? `手动探测失败:模型 ${unsupportedModels[0]} 不可用`
: `手动探测失败:${unsupportedModels.length} 个模型不可用(${unsupportedModels.slice(0, 3).join('、')}${unsupportedModels.length > 3 ? '…' : ''})`;
await setAccountRuntimeHealth(account.id, { state: 'unhealthy', reason, source: 'manual-probe', checkedAt });
rebuildTokenRoutesFromAvailability().catch((err) => {
console.warn('[probe-site-now] route rebuild failed', err);
});
}
return { success: true, scope, probed: details.length, unsupported: unsupportedModels.length, details };
}
async function runPostRefreshProbeIfEnabled(params: {
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
discoveredModels: string[];
}): Promise<ModelRefreshSuccessResult['postProbeResult']> {
if (!params.site.postRefreshProbeEnabled) return undefined;
if (params.discoveredModels.length === 0) return undefined;
const scope = (params.site.postRefreshProbeScope === 'all' ? 'all' : 'single') as 'single' | 'all';
// Determine which models to probe
let modelsToProbe: string[];
if (scope === 'all') {
modelsToProbe = params.discoveredModels;
} else {
const configModel = (params.site.postRefreshProbeModel || '').trim().toLowerCase();
const found = configModel
? (params.discoveredModels.find((m) => m.toLowerCase() === configModel) ?? params.discoveredModels[0])
: params.discoveredModels[0];
modelsToProbe = [found];
}
// Probe each model sequentially
const details: Array<{ modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }> = [];
for (const modelName of modelsToProbe) {
try {
const result = await probeRuntimeModel({
site: params.site,
account: params.account,
modelName,
timeoutMs: config.modelAvailabilityProbeTimeoutMs,
});
details.push({ modelName, status: result.status, latencyMs: result.latencyMs });
} catch (err) {
console.warn(`[post-refresh-probe] probe failed for account ${params.account.id} model ${modelName}`, err);
details.push({ modelName, status: 'inconclusive', latencyMs: null });
}
}
// Handle unsupported models
const unsupportedModels = details.filter((d) => d.status === 'unsupported' || d.status === 'inconclusive').map((d) => d.modelName);
if (unsupportedModels.length > 0) {
const checkedAt = new Date().toISOString();
for (const modelName of unsupportedModels) {
// Mark model as unavailable
await db.update(schema.modelAvailability)
.set({ available: false, checkedAt })
.where(and(
eq(schema.modelAvailability.accountId, params.account.id),
eq(schema.modelAvailability.modelName, modelName),
))
.run();
// Add to site-level disabled models
await db.insert(schema.siteDisabledModels)
.values({ siteId: params.site.id, modelName })
.onConflictDoNothing()
.run();
}
// Update account health
const reason = unsupportedModels.length === 1
? `刷新后探测失败:模型 ${unsupportedModels[0]} 不可用`
: `刷新后探测失败:${unsupportedModels.length} 个模型不可用(${unsupportedModels.slice(0, 3).join('、')}${unsupportedModels.length > 3 ? '…' : ''})`;
await setAccountRuntimeHealth(params.account.id, {
state: 'unhealthy',
reason,
source: 'post-refresh-probe',
checkedAt,
});
// Single route rebuild for all changes
rebuildTokenRoutesFromAvailability().catch((err) => {
console.warn('[post-refresh-probe] route rebuild failed', err);
});
}
return {
scope,
probed: details.length,
unsupported: unsupportedModels.length,
details,
};
}
export async function refreshModelsForAccount(
accountId: number,
options?: { allowInactive?: boolean },
@@ -510,6 +732,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery',
checkedAt,
});
const codexPostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: codexModels,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: codexModels.length,
@@ -517,6 +744,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
postProbeResult: codexPostProbeResult,
});
} catch (err) {
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
@@ -590,6 +818,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery',
checkedAt,
});
const claudePostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: claudeModels,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: claudeModels.length,
@@ -597,6 +830,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
postProbeResult: claudePostProbeResult,
});
} catch (err) {
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
@@ -684,6 +918,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery',
checkedAt,
});
const geminiPostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: GEMINI_CLI_STATIC_MODELS,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: GEMINI_CLI_STATIC_MODELS.length,
@@ -691,6 +930,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
postProbeResult: geminiPostProbeResult,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed';
@@ -764,6 +1004,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery',
checkedAt,
});
const antigravityPostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: antigravityModels,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: antigravityModels.length,
@@ -771,6 +1016,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
postProbeResult: antigravityPostProbeResult,
});
} catch (err) {
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
@@ -1021,6 +1267,11 @@ export async function refreshModelsForAccount(
});
const modelsPreview = Array.from(accountModels.values()).slice(0, 10);
const standardPostProbeResult = await runPostRefreshProbeIfEnabled({
account,
site,
discoveredModels: Array.from(accountModels.values()),
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: accountModels.size,
@@ -1028,6 +1279,7 @@ export async function refreshModelsForAccount(
tokenScanned: scannedTokenCount,
discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
postProbeResult: standardPostProbeResult,
});
}
+2
View File
@@ -786,6 +786,8 @@ export const api = {
}),
getSiteAvailableModels: (siteId: number) =>
request(`/api/sites/${siteId}/available-models`),
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string }) =>
request(`/api/sites/${siteId}/probe-now`, { method: 'POST', body: JSON.stringify(options || {}) }),
// Accounts
getAccounts: async (params?: { includeOauth?: boolean }) => {
+341
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { getAuthToken } from '../authSession.js';
import { getBrand } from '../components/BrandIcon.js';
import CenteredModal from '../components/CenteredModal.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
@@ -63,6 +64,9 @@ type SiteRow = {
totalBalance?: number;
subscriptionSummary?: SiteSubscriptionSummary | null;
createdAt?: string;
postRefreshProbeEnabled?: boolean;
postRefreshProbeModel?: string | null;
postRefreshProbeScope?: string | null;
apiEndpoints?: Array<{
id?: number;
url: string;
@@ -288,6 +292,17 @@ export default function Sites() {
const [disabledModelInput, setDisabledModelInput] = useState('');
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
const [probeEnabled, setProbeEnabled] = useState(false);
const [probeModel, setProbeModel] = useState('');
const [probeScope, setProbeScope] = useState<'single' | 'all'>('single');
const [probeSaving, setProbeSaving] = useState(false);
const [probeLatencyThreshold, setProbeLatencyThreshold] = useState('0');
const [probing, setProbing] = useState(false);
type ProbeLogEntry = { time: string; text: string; color?: string };
const [probeLog, setProbeLog] = useState<ProbeLogEntry[]>([]);
const [probeCompleted, setProbeCompleted] = useState(false);
const probeAbortRef = useRef<AbortController | null>(null);
const probeLogEndRef = useRef<HTMLDivElement | null>(null);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [disabledModelSearch, setDisabledModelSearch] = useState('');
const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []);
@@ -312,6 +327,13 @@ export default function Sites() {
latestInitializationPresetIdRef.current = selectedInitializationPresetId;
}, [selectedInitializationPresetId]);
useEffect(() => {
if (!editor) {
probeAbortRef.current?.abort();
probeAbortRef.current = null;
}
}, [editor]);
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
const brandGroups = useMemo(() => {
@@ -467,6 +489,13 @@ export default function Sites() {
setDisabledModelInput('');
setAvailableModels([]);
setDisabledModelSearch('');
setProbeEnabled(!!site.postRefreshProbeEnabled);
setProbeModel(typeof site.postRefreshProbeModel === 'string' ? site.postRefreshProbeModel : '');
setProbeScope(site.postRefreshProbeScope === 'all' ? 'all' : 'single');
setProbeLog([]);
setProbeCompleted(false);
probeAbortRef.current?.abort();
probeAbortRef.current = null;
let pendingLoads = 2;
const markLoadFinished = () => {
pendingLoads -= 1;
@@ -526,6 +555,150 @@ export default function Sites() {
}
};
const handleSaveProbeSettings = async () => {
if (!editor || editor.mode !== 'edit') return;
setProbeSaving(true);
try {
await api.updateSite(editor.editingSiteId, {
postRefreshProbeEnabled: probeEnabled,
postRefreshProbeModel: probeModel.trim(),
postRefreshProbeScope: probeScope,
});
setSites((prev) => prev.map((s) => s.id === editor.editingSiteId
? { ...s, postRefreshProbeEnabled: probeEnabled, postRefreshProbeModel: probeModel.trim(), postRefreshProbeScope: probeScope }
: s,
));
toast.success('刷新后探测设置已保存');
} catch (e: any) {
toast.error(e.message || '保存失败');
} finally {
setProbeSaving(false);
}
};
const handleProbeNow = async () => {
if (!editor || editor.mode !== 'edit') return;
const siteId = editor.editingSiteId;
const now = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
const addLog = (text: string, color?: string) =>
setProbeLog((prev) => [...prev, { time: now(), text, color }]);
probeAbortRef.current?.abort();
const controller = new AbortController();
probeAbortRef.current = controller;
setProbing(true);
setProbeLog([]);
try {
const token = getAuthToken(localStorage);
const params = new URLSearchParams({ scope: probeScope });
if (probeScope === 'single' && probeModel.trim()) params.set('modelName', probeModel.trim());
const threshold = parseInt(probeLatencyThreshold, 10);
if (Number.isFinite(threshold) && threshold > 0) params.set('latencyThresholdMs', String(threshold));
const res = await fetch(`/api/sites/${siteId}/probe-stream?${params}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: controller.signal,
});
if (!res.ok || !res.body) {
let errMsg = `连接失败 (HTTP ${res.status})`;
try { const j = await res.json() as any; errMsg = j?.error || j?.message || errMsg; } catch { /* ignore */ }
addLog(errMsg, 'var(--color-error, #ef4444)');
toast.error(errMsg);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
const handleSseEvent = (type: string, rawData: string) => {
try {
const d = JSON.parse(rawData);
if (type === 'start') {
addLog(`开始探测,范围:${d.scope === 'all' ? '全部模型' : '指定模型'},共 ${d.modelsCount} 个`);
} else if (type === 'model') {
const s = d.status === 'supported' ? '✓ 可用'
: d.status === 'unsupported'
? (d.latencyExceeded ? `✗ 延迟超限 (${d.latencyMs}ms)` : '✗ 不可用')
: d.status === 'skipped' ? '— 已跳过'
: '✗ 不可用';
const lat = d.latencyMs != null && d.status !== 'skipped' ? ` (${d.latencyMs}ms)` : '';
const c = d.status === 'supported' ? 'var(--color-success, #22c55e)'
: d.status === 'skipped' ? 'var(--color-text-muted)'
: 'var(--color-error, #ef4444)';
const reasonText = (() => {
if (!d.reason || d.status === 'supported' || d.status === 'skipped') return '';
const r = d.reason;
if (/timeout/i.test(r)) return '超时';
if (/missing credential|no.*token/i.test(r)) return '无 Token';
if (/no compatible.*endpoint|no.*endpoint candidate/i.test(r)) return '无可用端点';
if (/no such model|unknown model/i.test(r)) return '模型不存在';
if (/not found/i.test(r)) return '未找到';
if (/access denied|forbidden|permission/i.test(r)) return '无权限';
if (/rate.?limit|too many request/i.test(r)) return '触发频率限制';
if (/响应延迟/.test(r)) return r;
return r.length > 60 ? r.slice(0, 57) + '…' : r;
})();
addLog(`${s}${lat} ${d.modelName}${reasonText ? ` — ${reasonText}` : ''}`, c);
setTimeout(() => probeLogEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 30);
} else if (type === 'action') {
if (d.action === 'disabled') addLog(` ↳ 已加入站点禁用列表: ${d.modelName}`, 'var(--color-text-muted)');
} else if (type === 'complete') {
if (d.unsupported > 0) {
addLog(`完成:${d.probed} 个模型已探测,${d.unsupported} 个不可用已自动加入禁用列表`, 'var(--color-error, #ef4444)');
toast.error(`${d.unsupported} 个模型不可用,已自动加入站点禁用列表`);
} else {
addLog(`完成:${d.probed} 个模型均可用`, 'var(--color-success, #22c55e)');
toast.success(`探测完成:${d.probed} 个模型均可用`);
}
setTimeout(() => probeLogEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 30);
// Refresh model lists to reflect probe results
Promise.all([
api.getSiteAvailableModels(siteId).then((res: any) => {
setAvailableModels(Array.isArray(res?.models) ? res.models : []);
}),
api.getSiteDisabledModels(siteId).then((res: any) => {
setDisabledModels(Array.isArray(res?.models) ? res.models : []);
}),
]).catch(() => {}).finally(() => setProbeCompleted(true));
} else if (type === 'error') {
addLog(d.message || '探测失败', 'var(--color-error, #ef4444)');
toast.error(d.message || '探测失败');
}
} catch { /* ignore parse errors */ }
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() ?? '';
for (const part of parts) {
let eventType = 'message';
let data = '';
for (const line of part.split('\n')) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6).trim();
}
if (data) handleSseEvent(eventType, data);
}
}
} catch (e: any) {
if (e?.name === 'AbortError') {
setProbeLog((prev) => [...prev, { time: new Date().toLocaleTimeString('zh-CN', { hour12: false }), text: '已手动停止', color: 'var(--color-text-muted)' }]);
return;
}
addLog(e?.message || '探测失败', 'var(--color-error, #ef4444)');
toast.error(e?.message || '探测失败');
} finally {
setProbing(false);
probeAbortRef.current = null;
}
};
const handleSave = async () => {
if (!editor) return;
const parsedGlobalWeight = Number(form.globalWeight);
@@ -555,6 +728,9 @@ export default function Sites() {
apiEndpoints: serializedApiEndpoints.apiEndpoints,
customHeaders: serializedCustomHeaders.customHeaders,
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
postRefreshProbeEnabled: probeEnabled,
postRefreshProbeModel: probeModel.trim(),
postRefreshProbeScope: probeScope,
};
if (!payload.name || !payload.url) {
toast.error('请填写站点名称和 URL');
@@ -1485,6 +1661,171 @@ export default function Sites() {
</div>
)}
</div>
{isEditing && (
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>刷新后自动测试请求</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
开启后,每次自动获取模型列表成功后,会对指定模型发送一次真实测试请求。若判定不可用,自动加入站点禁用列表并重建路由。
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 10, cursor: 'pointer' }}>
<input
type="checkbox"
checked={probeEnabled}
onChange={(e) => setProbeEnabled(e.target.checked)}
style={{ width: 15, height: 15, marginTop: 2, flexShrink: 0 }}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>开启刷新后自动探测</span>
</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap', opacity: probeEnabled ? 1 : 0.5 }}>
{([['single', '指定模型'] , ['all', '全部模型']] as const).map(([val, label]) => (
<label
key={val}
style={{
display: 'flex', alignItems: 'center', gap: 6,
cursor: probeEnabled ? 'pointer' : 'default',
padding: '5px 12px', borderRadius: 'var(--radius-sm)', fontSize: 12,
border: `1px solid ${probeScope === val ? 'var(--color-primary)' : 'var(--color-border-light)'}`,
background: probeScope === val ? 'color-mix(in srgb, var(--color-primary) 8%, transparent)' : 'transparent',
userSelect: 'none',
}}
>
<input
type="radio"
name="siteProbeScope"
value={val}
checked={probeScope === val}
onChange={() => setProbeScope(val)}
disabled={!probeEnabled}
style={{ accentColor: 'var(--color-primary)', width: 13, height: 13 }}
/>
{label}
</label>
))}
</div>
{probeScope === 'single' && (
<input
type="text"
placeholder="探测模型名(留空则自动取第一个发现的模型)"
value={probeModel}
onChange={(e) => setProbeModel(e.target.value)}
disabled={!probeEnabled}
style={{
width: '100%', padding: '6px 10px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
marginBottom: 10, opacity: probeEnabled ? 1 : 0.5,
fontFamily: 'var(--font-mono)',
}}
/>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>延迟阈值</span>
<input
type="number"
min="0"
step="500"
placeholder="0"
value={probeLatencyThreshold}
onChange={(e) => setProbeLatencyThreshold(e.target.value)}
style={{
width: 90, padding: '5px 8px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
/>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>ms(响应超过该时间则自动禁用,0=不限)</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<button
onClick={() => void handleSaveProbeSettings()}
disabled={probeSaving || probing}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 16px', border: '1px solid var(--color-border)' }}
>
{probeSaving ? <><span className="spinner spinner-sm" /> 保存中...</> : '保存探测设置'}
</button>
<button
onClick={() => void handleProbeNow()}
disabled={probing || probeSaving}
className="btn btn-primary"
style={{ fontSize: 12, padding: '6px 16px' }}
>
{probing ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 探测中...</> : '立即探测'}
</button>
{probing && (
<button
onClick={() => { probeAbortRef.current?.abort(); }}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 16px', border: '1px solid var(--color-error, #ef4444)', color: 'var(--color-error, #ef4444)' }}
>
停止
</button>
)}
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{probeEnabled ? '实际探测超时复用「批量测活超时」设置' : '当前已关闭'}
</span>
</div>
{probeLog.length > 0 && (
<div style={{
marginTop: 10, padding: '8px 10px',
background: 'var(--color-bg)', border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)', fontSize: 11,
fontFamily: 'var(--font-mono)', maxHeight: 200, overflowY: 'auto',
lineHeight: 1.8,
}}>
{probeLog.map((entry, i) => (
<div key={i} style={{ color: entry.color || 'var(--color-text-secondary)' }}>
<span style={{ color: 'var(--color-text-muted)', marginRight: 8 }}>{entry.time}</span>
{entry.text}
</div>
))}
<div ref={probeLogEndRef} />
</div>
)}
{probeCompleted && brandGroups.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6, color: 'var(--color-text-secondary)' }}>
探测后模型状态
<span style={{ fontWeight: 400, marginLeft: 6, color: 'var(--color-text-muted)' }}>
— 可用 {availableModels.filter((m) => !disabledModelSet.has(m)).length} 个,已禁用 {disabledModels.length} 个
</span>
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '4px 0' }}>
{brandGroups.map(([brandName, models]) => (
<div key={brandName}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
{brandName} ({models.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, padding: '6px 12px' }}>
{models.map((model) => {
const isDisabled = disabledModelSet.has(model);
return (
<span
key={model}
style={{
fontSize: 11, padding: '2px 7px', borderRadius: 10,
fontFamily: 'var(--font-mono)',
background: isDisabled
? 'color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent)'
: 'color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent)',
color: isDisabled ? 'var(--color-error, #ef4444)' : 'var(--color-success, #22c55e)',
border: `1px solid ${isDisabled ? 'color-mix(in srgb, var(--color-error, #ef4444) 30%, transparent)' : 'color-mix(in srgb, var(--color-success, #22c55e) 30%, transparent)'}`,
}}
>
{model}
</span>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
<ResponsiveFormGrid>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input