Merge PR #510: per-site model probe

Merged after updating the PR onto current main and fixing the remaining probe-setting review gaps.\n\nValidation:\n- GitHub checks all passing: Test Core, Typecheck, Build Web/Server/Desktop, Repo Drift Check, Schema SQLite/MySQL/Postgres, CodeQL, CodeRabbit.\n- Local: npm run typecheck\n- Local: npm run repo:drift-check\n- Local: npx vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts src/web/api.test.ts src/web/pages/helpers/sitesEditor.test.ts src/web/pages/sites.disabled-models-save.test.tsx src/web/pages/sites.edit-scroll.test.tsx
This commit is contained in:
Cita
2026-05-08 16:58:27 +08:00
committed by GitHub
17 changed files with 809 additions and 3 deletions
+5
View File
@@ -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';
@@ -0,0 +1 @@
ALTER TABLE `sites` ADD `post_refresh_probe_latency_threshold_ms` integer DEFAULT 0;
+14
View File
@@ -169,6 +169,20 @@
"when": 1775731596826, "when": 1775731596826,
"tag": "0024_projection_leases", "tag": "0024_projection_leases",
"breakpoints": true "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
} }
] ]
} }
+1 -1
View File
@@ -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 `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 `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'))); 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')));
@@ -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 "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 "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')); 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'));
@@ -2089,6 +2089,30 @@
"notNull": false, "notNull": false,
"defaultValue": "1", "defaultValue": "1",
"primaryKey": false "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
} }
} }
}, },
+4
View File
@@ -15,6 +15,10 @@ export const sites = sqliteTable('sites', {
sortOrder: integer('sort_order').default(0), sortOrder: integer('sort_order').default(0),
globalWeight: real('global_weight').default(1), globalWeight: real('global_weight').default(1),
apiKey: text('api_key'), 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'))`), createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({ }, (table) => ({
+6 -1
View File
@@ -207,7 +207,12 @@ function normalizeDefaultValueForColumn(
if (rawDefaultValue == null) return null; if (rawDefaultValue == null) return null;
let normalized = String(rawDefaultValue).trim(); 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 = normalized.replace(/^default\s+/i, '').trim();
normalized = unwrapSurroundingParentheses(normalized); normalized = unwrapSurroundingParentheses(normalized);
+4
View File
@@ -702,6 +702,10 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.tokenRouterFailureCooldownMaxSec = normalized; config.tokenRouterFailureCooldownMaxSec = normalized;
return; return;
} }
case 'post_refresh_probe_enabled':
case 'post_refresh_probe_model':
case 'post_refresh_probe_scope':
return;
default: default:
return; return;
} }
+73
View File
@@ -18,6 +18,11 @@ import {
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js'; import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js'; import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
import { analyzePrimarySiteUrl } from '../../../shared/sitePrimaryUrl.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 { function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return 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.isPinned !== undefined) updates.isPinned = normalizedPinned;
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder; if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight; if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
const anyBody = body as Record<string, unknown>;
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(); updates.updatedAt = new Date().toISOString();
try { try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@@ -887,6 +900,66 @@ export async function sitesRoutes(app: FastifyInstance) {
return { siteId: id, models }; 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 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 // Detect platform for a URL
app.post<{ Body: unknown }>('/api/sites/detect', async (request, reply) => { app.post<{ Body: unknown }>('/api/sites/detect', async (request, reply) => {
const parsedBody = parseSiteDetectPayload(request.body); const parsedBody = parseSiteDetectPayload(request.body);
+12
View File
@@ -758,6 +758,10 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): {
sortOrder: section.sites.length, sortOrder: section.sites.length,
globalWeight: 1, globalWeight: 1,
apiKey: null, apiKey: null,
postRefreshProbeEnabled: false,
postRefreshProbeModel: '',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 0,
createdAt: input.createdAt, createdAt: input.createdAt,
updatedAt: input.updatedAt, updatedAt: input.updatedAt,
}); });
@@ -1001,6 +1005,10 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
sortOrder: sites.length, sortOrder: sites.length,
globalWeight: 1, globalWeight: 1,
apiKey: null, apiKey: null,
postRefreshProbeEnabled: false,
postRefreshProbeModel: '',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 0,
createdAt: toIsoString(item.created_at), createdAt: toIsoString(item.created_at),
updatedAt: toIsoString(item.updated_at), updatedAt: toIsoString(item.updated_at),
}); });
@@ -1546,6 +1554,10 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
sortOrder: row.sortOrder ?? 0, sortOrder: row.sortOrder ?? 0,
globalWeight: row.globalWeight ?? 1, globalWeight: row.globalWeight ?? 1,
apiKey: row.apiKey, apiKey: row.apiKey,
postRefreshProbeEnabled: row.postRefreshProbeEnabled ?? false,
postRefreshProbeModel: row.postRefreshProbeModel ?? '',
postRefreshProbeScope: (row.postRefreshProbeScope === 'all' ? 'all' : 'single') as 'single' | 'all',
postRefreshProbeLatencyThresholdMs: row.postRefreshProbeLatencyThresholdMs ?? 0,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}).run(); }).run();
+262
View File
@@ -34,6 +34,7 @@ import {
discoverCodexModelsFromCloud, discoverCodexModelsFromCloud,
validateGeminiCliOauthConnection, validateGeminiCliOauthConnection,
} from './platformDiscoveryRegistry.js'; } from './platformDiscoveryRegistry.js';
import { probeRuntimeModel, type RuntimeModelProbeStatus } from './runtimeModelProbe.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
@@ -101,6 +102,16 @@ export type ModelRefreshSuccessResult = {
tokenScanned: number; tokenScanned: number;
discoveredByCredential: boolean; discoveredByCredential: boolean;
discoveredApiToken: boolean; discoveredApiToken: boolean;
postProbeResult?: {
scope: 'single' | 'all';
probed: number;
unsupported: number;
details: Array<{
modelName: string;
status: RuntimeModelProbeStatus;
latencyMs: number | null;
}>;
};
}; };
export type ModelRefreshResult = export type ModelRefreshResult =
@@ -310,6 +321,7 @@ function buildSuccessfulRefreshResult(input: {
tokenScanned: number; tokenScanned: number;
discoveredByCredential: boolean; discoveredByCredential: boolean;
discoveredApiToken: boolean; discoveredApiToken: boolean;
postProbeResult?: ModelRefreshSuccessResult['postProbeResult'];
}): ModelRefreshSuccessResult { }): ModelRefreshSuccessResult {
return { return {
accountId: input.accountId, accountId: input.accountId,
@@ -322,6 +334,7 @@ function buildSuccessfulRefreshResult(input: {
tokenScanned: input.tokenScanned, tokenScanned: input.tokenScanned,
discoveredByCredential: input.discoveredByCredential, discoveredByCredential: input.discoveredByCredential,
discoveredApiToken: input.discoveredApiToken, discoveredApiToken: input.discoveredApiToken,
postProbeResult: input.postProbeResult,
}; };
} }
@@ -368,6 +381,225 @@ 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; signal?: AbortSignal },
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; reason?: string }>();
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<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];
}
// 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( export async function refreshModelsForAccount(
accountId: number, accountId: number,
options?: { allowInactive?: boolean }, options?: { allowInactive?: boolean },
@@ -510,6 +742,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery', source: 'model-discovery',
checkedAt, checkedAt,
}); });
const codexPostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: codexModels,
});
return buildSuccessfulRefreshResult({ return buildSuccessfulRefreshResult({
accountId, accountId,
modelCount: codexModels.length, modelCount: codexModels.length,
@@ -517,6 +754,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0, tokenScanned: 0,
discoveredByCredential: true, discoveredByCredential: true,
discoveredApiToken: false, discoveredApiToken: false,
postProbeResult: codexPostProbeResult,
}); });
} catch (err) { } catch (err) {
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
@@ -590,6 +828,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery', source: 'model-discovery',
checkedAt, checkedAt,
}); });
const claudePostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: claudeModels,
});
return buildSuccessfulRefreshResult({ return buildSuccessfulRefreshResult({
accountId, accountId,
modelCount: claudeModels.length, modelCount: claudeModels.length,
@@ -597,6 +840,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0, tokenScanned: 0,
discoveredByCredential: true, discoveredByCredential: true,
discoveredApiToken: false, discoveredApiToken: false,
postProbeResult: claudePostProbeResult,
}); });
} catch (err) { } catch (err) {
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
@@ -684,6 +928,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery', source: 'model-discovery',
checkedAt, checkedAt,
}); });
const geminiPostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: GEMINI_CLI_STATIC_MODELS,
});
return buildSuccessfulRefreshResult({ return buildSuccessfulRefreshResult({
accountId, accountId,
modelCount: GEMINI_CLI_STATIC_MODELS.length, modelCount: GEMINI_CLI_STATIC_MODELS.length,
@@ -691,6 +940,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0, tokenScanned: 0,
discoveredByCredential: true, discoveredByCredential: true,
discoveredApiToken: false, discoveredApiToken: false,
postProbeResult: geminiPostProbeResult,
}); });
} catch (err) { } catch (err) {
const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed'; const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed';
@@ -764,6 +1014,11 @@ export async function refreshModelsForAccount(
source: 'model-discovery', source: 'model-discovery',
checkedAt, checkedAt,
}); });
const antigravityPostProbeResult = await runPostRefreshProbeIfEnabled({
account: discoveryAccount,
site,
discoveredModels: antigravityModels,
});
return buildSuccessfulRefreshResult({ return buildSuccessfulRefreshResult({
accountId, accountId,
modelCount: antigravityModels.length, modelCount: antigravityModels.length,
@@ -771,6 +1026,7 @@ export async function refreshModelsForAccount(
tokenScanned: 0, tokenScanned: 0,
discoveredByCredential: true, discoveredByCredential: true,
discoveredApiToken: false, discoveredApiToken: false,
postProbeResult: antigravityPostProbeResult,
}); });
} catch (err) { } catch (err) {
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount; discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
@@ -1021,6 +1277,11 @@ export async function refreshModelsForAccount(
}); });
const modelsPreview = Array.from(accountModels.values()).slice(0, 10); const modelsPreview = Array.from(accountModels.values()).slice(0, 10);
const standardPostProbeResult = await runPostRefreshProbeIfEnabled({
account,
site,
discoveredModels: Array.from(accountModels.values()),
});
return buildSuccessfulRefreshResult({ return buildSuccessfulRefreshResult({
accountId, accountId,
modelCount: accountModels.size, modelCount: accountModels.size,
@@ -1028,6 +1289,7 @@ export async function refreshModelsForAccount(
tokenScanned: scannedTokenCount, tokenScanned: scannedTokenCount,
discoveredByCredential, discoveredByCredential,
discoveredApiToken: !!discoveredApiToken, discoveredApiToken: !!discoveredApiToken,
postProbeResult: standardPostProbeResult,
}); });
} }
+24
View File
@@ -98,6 +98,30 @@ describe('api proxy test timeout handling', () => {
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' }); 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 () => { it('times out replay hydration file-content fetches after 30 seconds', async () => {
installPendingFetch(); installPendingFetch();
+6
View File
@@ -786,6 +786,12 @@ export const api = {
}), }),
getSiteAvailableModels: (siteId: number) => getSiteAvailableModels: (siteId: number) =>
request(`/api/sites/${siteId}/available-models`), 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 // Accounts
getAccounts: async (params?: { includeOauth?: boolean }) => { getAccounts: async (params?: { includeOauth?: boolean }) => {
+360
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { getAuthToken } from '../authSession.js';
import { getBrand } from '../components/BrandIcon.js'; import { getBrand } from '../components/BrandIcon.js';
import CenteredModal from '../components/CenteredModal.js'; import CenteredModal from '../components/CenteredModal.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
@@ -63,6 +64,10 @@ type SiteRow = {
totalBalance?: number; totalBalance?: number;
subscriptionSummary?: SiteSubscriptionSummary | null; subscriptionSummary?: SiteSubscriptionSummary | null;
createdAt?: string; createdAt?: string;
postRefreshProbeEnabled?: boolean;
postRefreshProbeModel?: string | null;
postRefreshProbeScope?: string | null;
postRefreshProbeLatencyThresholdMs?: number | null;
apiEndpoints?: Array<{ apiEndpoints?: Array<{
id?: number; id?: number;
url: string; url: string;
@@ -288,6 +293,17 @@ export default function Sites() {
const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelInput, setDisabledModelInput] = useState('');
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
const [disabledModelsSaving, setDisabledModelsSaving] = 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 [availableModels, setAvailableModels] = useState<string[]>([]);
const [disabledModelSearch, setDisabledModelSearch] = useState(''); const [disabledModelSearch, setDisabledModelSearch] = useState('');
const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []); const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []);
@@ -312,6 +328,18 @@ export default function Sites() {
latestInitializationPresetIdRef.current = selectedInitializationPresetId; latestInitializationPresetIdRef.current = selectedInitializationPresetId;
}, [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 disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
const brandGroups = useMemo(() => { const brandGroups = useMemo(() => {
@@ -467,6 +495,14 @@ export default function Sites() {
setDisabledModelInput(''); setDisabledModelInput('');
setAvailableModels([]); setAvailableModels([]);
setDisabledModelSearch(''); 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; let pendingLoads = 2;
const markLoadFinished = () => { const markLoadFinished = () => {
pendingLoads -= 1; 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 () => { const handleSave = async () => {
if (!editor) return; if (!editor) return;
const parsedGlobalWeight = Number(form.globalWeight); const parsedGlobalWeight = Number(form.globalWeight);
@@ -555,6 +746,10 @@ export default function Sites() {
apiEndpoints: serializedApiEndpoints.apiEndpoints, apiEndpoints: serializedApiEndpoints.apiEndpoints,
customHeaders: serializedCustomHeaders.customHeaders, customHeaders: serializedCustomHeaders.customHeaders,
globalWeight: Number(parsedGlobalWeight.toFixed(3)), 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) { if (!payload.name || !payload.url) {
toast.error('请填写站点名称和 URL'); toast.error('请填写站点名称和 URL');
@@ -1485,6 +1680,171 @@ export default function Sites() {
</div> </div>
)} )}
</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> <ResponsiveFormGrid>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input <input
@@ -26,6 +26,10 @@ describe('buildSiteSaveAction', () => {
customHeaders: '{"x-site-token":"alpha"}', customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false, useSystemProxy: false,
globalWeight: 1.2, globalWeight: 1.2,
postRefreshProbeEnabled: true,
postRefreshProbeModel: 'gpt-4o',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 2500,
}, },
); );
@@ -44,6 +48,10 @@ describe('buildSiteSaveAction', () => {
customHeaders: '{"x-site-token":"alpha"}', customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false, useSystemProxy: false,
globalWeight: 1.2, globalWeight: 1.2,
postRefreshProbeEnabled: true,
postRefreshProbeModel: 'gpt-4o',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 2500,
}, },
}); });
}); });
+4
View File
@@ -42,6 +42,10 @@ export type SiteSavePayload = {
}>; }>;
customHeaders: string; customHeaders: string;
globalWeight: number; globalWeight: number;
postRefreshProbeEnabled?: boolean;
postRefreshProbeModel?: string;
postRefreshProbeScope?: 'single' | 'all';
postRefreshProbeLatencyThresholdMs?: number;
}; };
type SiteSaveAction = type SiteSaveAction =