diff --git a/drizzle/0025_site_post_refresh_probe.sql b/drizzle/0025_site_post_refresh_probe.sql new file mode 100644 index 00000000..f7afe2ae --- /dev/null +++ b/drizzle/0025_site_post_refresh_probe.sql @@ -0,0 +1,5 @@ +ALTER TABLE `sites` ADD `post_refresh_probe_enabled` integer DEFAULT false; +--> statement-breakpoint +ALTER TABLE `sites` ADD `post_refresh_probe_model` text DEFAULT ''; +--> statement-breakpoint +ALTER TABLE `sites` ADD `post_refresh_probe_scope` text DEFAULT 'single'; diff --git a/drizzle/0026_site_probe_latency_threshold.sql b/drizzle/0026_site_probe_latency_threshold.sql new file mode 100644 index 00000000..393869e0 --- /dev/null +++ b/drizzle/0026_site_probe_latency_threshold.sql @@ -0,0 +1 @@ +ALTER TABLE `sites` ADD `post_refresh_probe_latency_threshold_ms` integer DEFAULT 0; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 66ddd28a..ffbbaa4d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,20 @@ "when": 1775731596826, "tag": "0024_projection_leases", "breakpoints": true + }, + { + "idx": 24, + "version": "6", + "when": 1776943800000, + "tag": "0025_site_post_refresh_probe", + "breakpoints": true + }, + { + "idx": 25, + "version": "6", + "when": 1776944000000, + "tag": "0026_site_probe_latency_threshold", + "breakpoints": true } ] } diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index d75f6ad5..35455582 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1); +CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1, `post_refresh_probe_enabled` BOOLEAN DEFAULT false, `post_refresh_probe_model` VARCHAR(191) DEFAULT '', `post_refresh_probe_scope` VARCHAR(191) DEFAULT 'single', `post_refresh_probe_latency_threshold_ms` INT DEFAULT 0); CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `admin_snapshots` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `namespace` TEXT NOT NULL, `snapshot_key` TEXT NOT NULL, `payload` TEXT NOT NULL, `generated_at` VARCHAR(191) NOT NULL, `expires_at` VARCHAR(191) NOT NULL, `stale_until` VARCHAR(191) NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index a4c4e1a2..6b79075a 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1); +CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1, "post_refresh_probe_enabled" BOOLEAN DEFAULT false, "post_refresh_probe_model" TEXT DEFAULT '', "post_refresh_probe_scope" TEXT DEFAULT 'single', "post_refresh_probe_latency_threshold_ms" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "admin_snapshots" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "namespace" TEXT NOT NULL, "snapshot_key" TEXT NOT NULL, "payload" TEXT NOT NULL, "generated_at" TEXT NOT NULL, "expires_at" TEXT NOT NULL, "stale_until" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index 774e81da..eefc6a69 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -2089,6 +2089,30 @@ "notNull": false, "defaultValue": "1", "primaryKey": false + }, + "post_refresh_probe_enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "post_refresh_probe_model": { + "logicalType": "text", + "notNull": false, + "defaultValue": "''", + "primaryKey": false + }, + "post_refresh_probe_scope": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'single'", + "primaryKey": false + }, + "post_refresh_probe_latency_threshold_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false } } }, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 99cc0079..93288d79 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -15,6 +15,10 @@ 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'), + postRefreshProbeLatencyThresholdMs: integer('post_refresh_probe_latency_threshold_ms').default(0), createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), }, (table) => ({ diff --git a/src/server/db/schemaIntrospection.ts b/src/server/db/schemaIntrospection.ts index 81a64722..8f74e767 100644 --- a/src/server/db/schemaIntrospection.ts +++ b/src/server/db/schemaIntrospection.ts @@ -207,7 +207,12 @@ function normalizeDefaultValueForColumn( if (rawDefaultValue == null) return null; let normalized = String(rawDefaultValue).trim(); - if (!normalized) return null; + // MySQL stores DEFAULT '' as an empty string in information_schema.COLUMNS.COLUMN_DEFAULT. + // An empty string here is a valid empty-string literal default, not "no default". + // Encode it as two single-quotes so it round-trips through schemaContract correctly. + if (!normalized) { + return (logicalType === 'text' || logicalType === 'json') ? "''" : null; + } normalized = normalized.replace(/^default\s+/i, '').trim(); normalized = unwrapSurroundingParentheses(normalized); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index e6273f50..75d94a37 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -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; } diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 7e27c234..4645444e 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -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,14 @@ 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; + if (anyBody.postRefreshProbeEnabled !== undefined) updates.postRefreshProbeEnabled = anyBody.postRefreshProbeEnabled === true || anyBody.postRefreshProbeEnabled === 1; + if (anyBody.postRefreshProbeModel !== undefined) updates.postRefreshProbeModel = String(anyBody.postRefreshProbeModel || '').trim(); + if (anyBody.postRefreshProbeScope !== undefined) updates.postRefreshProbeScope = anyBody.postRefreshProbeScope === 'all' ? 'all' : 'single'; + if (anyBody.postRefreshProbeLatencyThresholdMs !== undefined) { + const ms = Number(anyBody.postRefreshProbeLatencyThresholdMs); + updates.postRefreshProbeLatencyThresholdMs = Number.isFinite(ms) && ms >= 0 ? Math.trunc(ms) : 0; + } updates.updatedAt = new Date().toISOString(); try { await db.transaction(async (tx) => { @@ -887,6 +900,66 @@ 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 | null; + const scope = body?.scope === 'all' ? 'all' : body?.scope === 'single' ? 'single' : undefined; + const modelName = typeof body?.modelName === 'string' ? body.modelName.trim() : undefined; + const parsedThresholdBody = Number(body?.latencyThresholdMs ?? 0); + const latencyThresholdMsBody = Number.isFinite(parsedThresholdBody) && parsedThresholdBody > 0 ? Math.trunc(parsedThresholdBody) : undefined; + const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs: latencyThresholdMsBody }); + 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; + + // Propagate client disconnect to the probe worker pool + const probeAbort = new AbortController(); + reply.raw.on('close', () => probeAbort.abort()); + + try { + const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs, signal: probeAbort.signal }, (ev) => { + sseWrite(reply.raw, ev.type, ev); + }); + if (!probeAbort.signal.aborted) { + 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); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index a25ff475..61c7eb86 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -758,6 +758,10 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): { sortOrder: section.sites.length, globalWeight: 1, apiKey: null, + postRefreshProbeEnabled: false, + postRefreshProbeModel: '', + postRefreshProbeScope: 'single', + postRefreshProbeLatencyThresholdMs: 0, createdAt: input.createdAt, updatedAt: input.updatedAt, }); @@ -1001,6 +1005,10 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS sortOrder: sites.length, globalWeight: 1, apiKey: null, + postRefreshProbeEnabled: false, + postRefreshProbeModel: '', + postRefreshProbeScope: 'single', + postRefreshProbeLatencyThresholdMs: 0, createdAt: toIsoString(item.created_at), updatedAt: toIsoString(item.updated_at), }); @@ -1546,6 +1554,10 @@ async function importAccountsSection(section: AccountsBackupSection): Promise; + }; }; 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,225 @@ async function retryOauthModelDiscoveryWithRefresh(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; signal?: AbortSignal }, + onProgress?: (event: ProbeSiteModelsProgress) => void, +): Promise { + 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(); + + let cursor = 0; + async function worker() { + while (cursor < modelsToProbe.length) { + if (options?.signal?.aborted) break; + 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 { + 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]; + } + + // runPostRefreshProbeIfEnabled: apply latency threshold from site config + const threshold = params.site.postRefreshProbeLatencyThresholdMs ?? 0; + // 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, + }); + const latencyExceeded = ( + result.status === 'supported' + && threshold > 0 + && result.latencyMs != null + && result.latencyMs > threshold + ); + const effectiveStatus: RuntimeModelProbeStatus = latencyExceeded ? 'unsupported' : result.status; + details.push({ modelName, status: effectiveStatus, 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 +742,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 +754,7 @@ export async function refreshModelsForAccount( tokenScanned: 0, discoveredByCredential: true, discoveredApiToken: false, + postProbeResult: codexPostProbeResult, }); } catch (err) { discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; @@ -590,6 +828,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 +840,7 @@ export async function refreshModelsForAccount( tokenScanned: 0, discoveredByCredential: true, discoveredApiToken: false, + postProbeResult: claudePostProbeResult, }); } catch (err) { discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; @@ -684,6 +928,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 +940,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 +1014,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 +1026,7 @@ export async function refreshModelsForAccount( tokenScanned: 0, discoveredByCredential: true, discoveredApiToken: false, + postProbeResult: antigravityPostProbeResult, }); } catch (err) { discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; @@ -1021,6 +1277,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 +1289,7 @@ export async function refreshModelsForAccount( tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, + postProbeResult: standardPostProbeResult, }); } diff --git a/src/web/api.test.ts b/src/web/api.test.ts index 0b11c4a4..28da4dc2 100644 --- a/src/web/api.test.ts +++ b/src/web/api.test.ts @@ -98,6 +98,30 @@ describe('api proxy test timeout handling', () => { await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' }); }); + it('keeps all-model site probes alive past the default 30 second timeout', async () => { + installPendingFetch(); + + let settled = false; + const promise = api.probeSiteNow(1, { scope: 'all' }); + const handled = promise + .then(() => ({ ok: true as const })) + .catch((error: Error) => ({ ok: false as const, error })) + .finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(30_000); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(90_000); + const result = await handled; + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('Expected all-model site probe to time out'); + } + expect(result.error.message).toBe('请求超时(120s)'); + }); + it('times out replay hydration file-content fetches after 30 seconds', async () => { installPendingFetch(); diff --git a/src/web/api.ts b/src/web/api.ts index 6d42185c..1f8dfdbb 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -786,6 +786,12 @@ export const api = { }), getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`), + probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) => + request(`/api/sites/${siteId}/probe-now`, { + method: 'POST', + body: JSON.stringify(options || {}), + timeoutMs: options?.scope === 'all' ? 120_000 : 30_000, + }), // Accounts getAccounts: async (params?: { includeOauth?: boolean }) => { diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 1438e9c1..b5a7b793 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -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,10 @@ type SiteRow = { totalBalance?: number; subscriptionSummary?: SiteSubscriptionSummary | null; createdAt?: string; + postRefreshProbeEnabled?: boolean; + postRefreshProbeModel?: string | null; + postRefreshProbeScope?: string | null; + postRefreshProbeLatencyThresholdMs?: number | null; apiEndpoints?: Array<{ id?: number; url: string; @@ -288,6 +293,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([]); + const [probeCompleted, setProbeCompleted] = useState(false); + const probeAbortRef = useRef(null); + const probeLogEndRef = useRef(null); const [availableModels, setAvailableModels] = useState([]); const [disabledModelSearch, setDisabledModelSearch] = useState(''); const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []); @@ -312,6 +328,18 @@ export default function Sites() { latestInitializationPresetIdRef.current = selectedInitializationPresetId; }, [selectedInitializationPresetId]); + useEffect(() => { + if (!editor) { + probeAbortRef.current?.abort(); + probeAbortRef.current = null; + } + }, [editor]); + + useEffect(() => () => { + probeAbortRef.current?.abort(); + probeAbortRef.current = null; + }, []); + const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]); const brandGroups = useMemo(() => { @@ -467,6 +495,14 @@ export default function Sites() { setDisabledModelInput(''); setAvailableModels([]); setDisabledModelSearch(''); + setProbeEnabled(!!site.postRefreshProbeEnabled); + setProbeModel(typeof site.postRefreshProbeModel === 'string' ? site.postRefreshProbeModel : ''); + setProbeScope(site.postRefreshProbeScope === 'all' ? 'all' : 'single'); + setProbeLatencyThreshold(String(site.postRefreshProbeLatencyThresholdMs ?? 0)); + setProbeLog([]); + setProbeCompleted(false); + probeAbortRef.current?.abort(); + probeAbortRef.current = null; let pendingLoads = 2; const markLoadFinished = () => { pendingLoads -= 1; @@ -526,6 +562,161 @@ 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, + postRefreshProbeLatencyThresholdMs: Math.max(0, parseInt(probeLatencyThreshold, 10) || 0), + }); + setSites((prev) => prev.map((s) => s.id === editor.editingSiteId + ? { ...s, postRefreshProbeEnabled: probeEnabled, postRefreshProbeModel: probeModel.trim(), postRefreshProbeScope: probeScope, postRefreshProbeLatencyThresholdMs: Math.max(0, parseInt(probeLatencyThreshold, 10) || 0) } + : 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([]); + setProbeCompleted(false); + + 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 || '探测失败'); + // Refresh model state even on error + 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)); + } + } 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 +746,10 @@ export default function Sites() { apiEndpoints: serializedApiEndpoints.apiEndpoints, customHeaders: serializedCustomHeaders.customHeaders, globalWeight: Number(parsedGlobalWeight.toFixed(3)), + postRefreshProbeEnabled: probeEnabled, + postRefreshProbeModel: probeModel.trim(), + postRefreshProbeScope: probeScope, + postRefreshProbeLatencyThresholdMs: Math.max(0, parseInt(probeLatencyThreshold, 10) || 0), }; if (!payload.name || !payload.url) { toast.error('请填写站点名称和 URL'); @@ -1485,6 +1680,171 @@ export default function Sites() { )} + + {isEditing && ( +
+
刷新后自动测试请求
+
+ 开启后,每次自动获取模型列表成功后,会对指定模型发送一次真实测试请求。若判定不可用,自动加入站点禁用列表并重建路由。 +
+ +
+ {([['single', '指定模型'] , ['all', '全部模型']] as const).map(([val, label]) => ( + + ))} +
+ {probeScope === 'single' && ( + 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)', + }} + /> + )} +
+ 延迟阈值 + 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)', + }} + /> + ms(响应超过该时间则自动禁用,0=不限) +
+
+ + + {probing && ( + + )} + + {probeEnabled ? '实际探测超时复用「批量测活超时」设置' : '当前已关闭'} + +
+ {probeLog.length > 0 && ( +
+ {probeLog.map((entry, i) => ( +
+ {entry.time} + {entry.text} +
+ ))} +
+
+ )} + {probeCompleted && brandGroups.length > 0 && ( +
+
+ 探测后模型状态 + + — 可用 {availableModels.filter((m) => !disabledModelSet.has(m)).length} 个,已禁用 {disabledModels.length} 个 + +
+
+ {brandGroups.map(([brandName, models]) => ( +
+
+ {brandName} ({models.length}) +
+
+ {models.map((model) => { + const isDisabled = disabledModelSet.has(model); + return ( + + {model} + + ); + })} +
+
+ ))} +
+
+ )} +
+ )} +
{ customHeaders: '{"x-site-token":"alpha"}', useSystemProxy: false, globalWeight: 1.2, + postRefreshProbeEnabled: true, + postRefreshProbeModel: 'gpt-4o', + postRefreshProbeScope: 'single', + postRefreshProbeLatencyThresholdMs: 2500, }, ); @@ -44,6 +48,10 @@ describe('buildSiteSaveAction', () => { customHeaders: '{"x-site-token":"alpha"}', useSystemProxy: false, globalWeight: 1.2, + postRefreshProbeEnabled: true, + postRefreshProbeModel: 'gpt-4o', + postRefreshProbeScope: 'single', + postRefreshProbeLatencyThresholdMs: 2500, }, }); }); diff --git a/src/web/pages/helpers/sitesEditor.ts b/src/web/pages/helpers/sitesEditor.ts index 49907a2e..2e3639df 100644 --- a/src/web/pages/helpers/sitesEditor.ts +++ b/src/web/pages/helpers/sitesEditor.ts @@ -42,6 +42,10 @@ export type SiteSavePayload = { }>; customHeaders: string; globalWeight: number; + postRefreshProbeEnabled?: boolean; + postRefreshProbeModel?: string; + postRefreshProbeScope?: 'single' | 'all'; + postRefreshProbeLatencyThresholdMs?: number; }; type SiteSaveAction =