feat(sites): per-site model probe with streaming logs and auto-disable
- Move post-refresh probe settings from global config to per-site (sites table) - Add manual 'probe now' button in site editor with real-time SSE log stream - Use fetch+ReadableStream instead of EventSource to support Authorization header - Concurrent probe execution (default 10 workers) via worker-pool pattern - Add stop button to cancel in-flight probe while preserving completed results - Add latency threshold: auto-disable models exceeding response time limit - Treat inconclusive probe results as unsupported (auto-disable) - Show specific failure reason in logs (timeout, no token, model not found, etc.) - Refresh and display post-probe model status list (green=available, red=disabled) - Remove probe settings from global Settings page and RuntimeSettingsPayload
This commit is contained in:
@@ -15,6 +15,9 @@ 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'),
|
||||||
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) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 @@ 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 = Boolean(anyBody.postRefreshProbeEnabled);
|
||||||
|
if (anyBody.postRefreshProbeModel !== undefined) updates.postRefreshProbeModel = String(anyBody.postRefreshProbeModel || '').trim();
|
||||||
|
if (anyBody.postRefreshProbeScope !== undefined) updates.postRefreshProbeScope = anyBody.postRefreshProbeScope === 'all' ? 'all' : 'single';
|
||||||
updates.updatedAt = new Date().toISOString();
|
updates.updatedAt = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
@@ -887,6 +896,58 @@ 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 result = await probeSiteModels(id, { scope, modelName });
|
||||||
|
if (!result.success) {
|
||||||
|
return reply.code(422).send({ error: result.error });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Streaming probe via SSE
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { scope?: string; modelName?: string; latencyThresholdMs?: string } }>(
|
||||||
|
'/api/sites/:id/probe-stream',
|
||||||
|
async (request, reply) => {
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = parseInt(request.params.id);
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
sseWrite(reply.raw, 'error', { message: 'Invalid site id' });
|
||||||
|
reply.raw.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = request.query;
|
||||||
|
const scope = q.scope === 'all' ? 'all' : q.scope === 'single' ? 'single' : undefined;
|
||||||
|
const modelName = q.modelName?.trim() || undefined;
|
||||||
|
const parsedThreshold = parseInt(q.latencyThresholdMs ?? '', 10);
|
||||||
|
const latencyThresholdMs = Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs }, (ev) => {
|
||||||
|
sseWrite(reply.raw, ev.type, ev);
|
||||||
|
});
|
||||||
|
sseWrite(reply.raw, 'complete', result);
|
||||||
|
} catch (err: any) {
|
||||||
|
sseWrite(reply.raw, 'error', { message: err?.message || '探测失败' });
|
||||||
|
}
|
||||||
|
reply.raw.end();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Detect platform for a URL
|
// 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);
|
||||||
|
|||||||
@@ -758,6 +758,9 @@ 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',
|
||||||
createdAt: input.createdAt,
|
createdAt: input.createdAt,
|
||||||
updatedAt: input.updatedAt,
|
updatedAt: input.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -1001,6 +1004,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
|||||||
sortOrder: sites.length,
|
sortOrder: sites.length,
|
||||||
globalWeight: 1,
|
globalWeight: 1,
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
|
postRefreshProbeEnabled: false,
|
||||||
|
postRefreshProbeModel: '',
|
||||||
|
postRefreshProbeScope: 'single',
|
||||||
createdAt: toIsoString(item.created_at),
|
createdAt: toIsoString(item.created_at),
|
||||||
updatedAt: toIsoString(item.updated_at),
|
updatedAt: toIsoString(item.updated_at),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,215 @@ async function retryOauthModelDiscoveryWithRefresh<T>(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProbeSiteModelsResult = {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
scope: 'single' | 'all';
|
||||||
|
probed: number;
|
||||||
|
unsupported: number;
|
||||||
|
details: Array<{ modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null; reason?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProbeSiteModelsProgress =
|
||||||
|
| { type: 'start'; scope: 'single' | 'all'; modelsCount: number; modelsToProbe: string[] }
|
||||||
|
| { type: 'model'; modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null; latencyExceeded?: true; reason?: string }
|
||||||
|
| { type: 'action'; modelName: string; action: 'disabled' };
|
||||||
|
|
||||||
|
export async function probeSiteModels(
|
||||||
|
siteId: number,
|
||||||
|
options?: { scope?: 'single' | 'all'; modelName?: string; concurrency?: number; latencyThresholdMs?: number },
|
||||||
|
onProgress?: (event: ProbeSiteModelsProgress) => void,
|
||||||
|
): Promise<ProbeSiteModelsResult> {
|
||||||
|
const empty = (scope: 'single' | 'all', error: string): ProbeSiteModelsResult =>
|
||||||
|
({ success: false, error, scope, probed: 0, unsupported: 0, details: [] });
|
||||||
|
|
||||||
|
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||||
|
if (!site) return empty('single', '站点不存在');
|
||||||
|
|
||||||
|
const account = await db.select().from(schema.accounts)
|
||||||
|
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.status, 'active')))
|
||||||
|
.get();
|
||||||
|
if (!account) return empty('single', '该站点没有可用的活跃账号');
|
||||||
|
|
||||||
|
const modelRows = await db.select({ modelName: schema.modelAvailability.modelName })
|
||||||
|
.from(schema.modelAvailability)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.modelAvailability.accountId, account.id),
|
||||||
|
eq(schema.modelAvailability.available, true),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const scope = (options?.scope ?? (site.postRefreshProbeScope === 'all' ? 'all' : 'single')) as 'single' | 'all';
|
||||||
|
const availableModels = modelRows.map((r) => r.modelName.trim()).filter((m) => m.length > 0);
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
return empty(scope, '该站点暂无已发现模型,请先刷新模型列表');
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelsToProbe: string[];
|
||||||
|
if (scope === 'all') {
|
||||||
|
modelsToProbe = availableModels;
|
||||||
|
} else {
|
||||||
|
const configModel = ((options?.modelName ?? site.postRefreshProbeModel) || '').trim().toLowerCase();
|
||||||
|
const found = configModel
|
||||||
|
? (availableModels.find((m) => m.toLowerCase() === configModel) ?? availableModels[0])
|
||||||
|
: availableModels[0];
|
||||||
|
modelsToProbe = [found];
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({ type: 'start', scope, modelsCount: modelsToProbe.length, modelsToProbe });
|
||||||
|
|
||||||
|
// Probe models concurrently, limited by modelAvailabilityProbeConcurrency
|
||||||
|
const concurrency = Math.max(1, options?.concurrency ?? 10);
|
||||||
|
const detailsMap = new Map<string, { modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }>();
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < modelsToProbe.length) {
|
||||||
|
const modelName = modelsToProbe[cursor++];
|
||||||
|
try {
|
||||||
|
const result = await probeRuntimeModel({
|
||||||
|
site, account, modelName, timeoutMs: config.modelAvailabilityProbeTimeoutMs,
|
||||||
|
});
|
||||||
|
const threshold = options?.latencyThresholdMs ?? 0;
|
||||||
|
const latencyExceeded = (
|
||||||
|
result.status === 'supported'
|
||||||
|
&& threshold > 0
|
||||||
|
&& result.latencyMs != null
|
||||||
|
&& result.latencyMs > threshold
|
||||||
|
);
|
||||||
|
const effectiveStatus: RuntimeModelProbeStatus = latencyExceeded ? 'unsupported' : result.status;
|
||||||
|
const effectiveReason = latencyExceeded
|
||||||
|
? `响应延迟 ${result.latencyMs}ms 超过阈值 ${threshold}ms`
|
||||||
|
: result.reason;
|
||||||
|
detailsMap.set(modelName, { modelName, status: effectiveStatus, latencyMs: result.latencyMs, reason: effectiveReason });
|
||||||
|
onProgress?.(latencyExceeded
|
||||||
|
? { type: 'model', modelName, status: effectiveStatus, latencyMs: result.latencyMs, latencyExceeded: true, reason: effectiveReason }
|
||||||
|
: { type: 'model', modelName, status: effectiveStatus, latencyMs: result.latencyMs, reason: effectiveReason },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const errReason = err instanceof Error ? err.message : '探测异常';
|
||||||
|
console.warn(`[probe-site-now] probe failed for site ${siteId} model ${modelName}`, err);
|
||||||
|
detailsMap.set(modelName, { modelName, status: 'inconclusive', latencyMs: null, reason: errReason });
|
||||||
|
onProgress?.({ type: 'model', modelName, status: 'inconclusive', latencyMs: null, reason: errReason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, modelsToProbe.length) }, worker));
|
||||||
|
|
||||||
|
// Restore original model order for the final details list
|
||||||
|
const details = modelsToProbe.map((m) => detailsMap.get(m)!);
|
||||||
|
|
||||||
|
const unsupportedModels = details.filter((d) => d.status === 'unsupported' || d.status === 'inconclusive').map((d) => d.modelName);
|
||||||
|
if (unsupportedModels.length > 0) {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
for (const modelName of unsupportedModels) {
|
||||||
|
await db.update(schema.modelAvailability)
|
||||||
|
.set({ available: false, checkedAt })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.modelAvailability.accountId, account.id),
|
||||||
|
eq(schema.modelAvailability.modelName, modelName),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
await db.insert(schema.siteDisabledModels)
|
||||||
|
.values({ siteId, modelName })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run();
|
||||||
|
onProgress?.({ type: 'action', modelName, action: 'disabled' });
|
||||||
|
}
|
||||||
|
const reason = unsupportedModels.length === 1
|
||||||
|
? `手动探测失败:模型 ${unsupportedModels[0]} 不可用`
|
||||||
|
: `手动探测失败:${unsupportedModels.length} 个模型不可用(${unsupportedModels.slice(0, 3).join('、')}${unsupportedModels.length > 3 ? '…' : ''})`;
|
||||||
|
await setAccountRuntimeHealth(account.id, { state: 'unhealthy', reason, source: 'manual-probe', checkedAt });
|
||||||
|
rebuildTokenRoutesFromAvailability().catch((err) => {
|
||||||
|
console.warn('[probe-site-now] route rebuild failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, scope, probed: details.length, unsupported: unsupportedModels.length, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPostRefreshProbeIfEnabled(params: {
|
||||||
|
account: typeof schema.accounts.$inferSelect;
|
||||||
|
site: typeof schema.sites.$inferSelect;
|
||||||
|
discoveredModels: string[];
|
||||||
|
}): Promise<ModelRefreshSuccessResult['postProbeResult']> {
|
||||||
|
if (!params.site.postRefreshProbeEnabled) return undefined;
|
||||||
|
if (params.discoveredModels.length === 0) return undefined;
|
||||||
|
|
||||||
|
const scope = (params.site.postRefreshProbeScope === 'all' ? 'all' : 'single') as 'single' | 'all';
|
||||||
|
|
||||||
|
// Determine which models to probe
|
||||||
|
let modelsToProbe: string[];
|
||||||
|
if (scope === 'all') {
|
||||||
|
modelsToProbe = params.discoveredModels;
|
||||||
|
} else {
|
||||||
|
const configModel = (params.site.postRefreshProbeModel || '').trim().toLowerCase();
|
||||||
|
const found = configModel
|
||||||
|
? (params.discoveredModels.find((m) => m.toLowerCase() === configModel) ?? params.discoveredModels[0])
|
||||||
|
: params.discoveredModels[0];
|
||||||
|
modelsToProbe = [found];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe each model sequentially
|
||||||
|
const details: Array<{ modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }> = [];
|
||||||
|
for (const modelName of modelsToProbe) {
|
||||||
|
try {
|
||||||
|
const result = await probeRuntimeModel({
|
||||||
|
site: params.site,
|
||||||
|
account: params.account,
|
||||||
|
modelName,
|
||||||
|
timeoutMs: config.modelAvailabilityProbeTimeoutMs,
|
||||||
|
});
|
||||||
|
details.push({ modelName, status: result.status, latencyMs: result.latencyMs });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[post-refresh-probe] probe failed for account ${params.account.id} model ${modelName}`, err);
|
||||||
|
details.push({ modelName, status: 'inconclusive', latencyMs: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unsupported models
|
||||||
|
const unsupportedModels = details.filter((d) => d.status === 'unsupported' || d.status === 'inconclusive').map((d) => d.modelName);
|
||||||
|
if (unsupportedModels.length > 0) {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
for (const modelName of unsupportedModels) {
|
||||||
|
// Mark model as unavailable
|
||||||
|
await db.update(schema.modelAvailability)
|
||||||
|
.set({ available: false, checkedAt })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.modelAvailability.accountId, params.account.id),
|
||||||
|
eq(schema.modelAvailability.modelName, modelName),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
// Add to site-level disabled models
|
||||||
|
await db.insert(schema.siteDisabledModels)
|
||||||
|
.values({ siteId: params.site.id, modelName })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
// Update account health
|
||||||
|
const reason = unsupportedModels.length === 1
|
||||||
|
? `刷新后探测失败:模型 ${unsupportedModels[0]} 不可用`
|
||||||
|
: `刷新后探测失败:${unsupportedModels.length} 个模型不可用(${unsupportedModels.slice(0, 3).join('、')}${unsupportedModels.length > 3 ? '…' : ''})`;
|
||||||
|
await setAccountRuntimeHealth(params.account.id, {
|
||||||
|
state: 'unhealthy',
|
||||||
|
reason,
|
||||||
|
source: 'post-refresh-probe',
|
||||||
|
checkedAt,
|
||||||
|
});
|
||||||
|
// Single route rebuild for all changes
|
||||||
|
rebuildTokenRoutesFromAvailability().catch((err) => {
|
||||||
|
console.warn('[post-refresh-probe] route rebuild failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
probed: details.length,
|
||||||
|
unsupported: unsupportedModels.length,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshModelsForAccount(
|
export async function refreshModelsForAccount(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
options?: { allowInactive?: boolean },
|
options?: { allowInactive?: boolean },
|
||||||
@@ -510,6 +732,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 +744,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 +818,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 +830,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 +918,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 +930,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 +1004,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 +1016,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 +1267,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 +1279,7 @@ export async function refreshModelsForAccount(
|
|||||||
tokenScanned: scannedTokenCount,
|
tokenScanned: scannedTokenCount,
|
||||||
discoveredByCredential,
|
discoveredByCredential,
|
||||||
discoveredApiToken: !!discoveredApiToken,
|
discoveredApiToken: !!discoveredApiToken,
|
||||||
|
postProbeResult: standardPostProbeResult,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -786,6 +786,8 @@ 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 }) =>
|
||||||
|
request(`/api/sites/${siteId}/probe-now`, { method: 'POST', body: JSON.stringify(options || {}) }),
|
||||||
|
|
||||||
// Accounts
|
// Accounts
|
||||||
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
getAccounts: async (params?: { includeOauth?: boolean }) => {
|
||||||
|
|||||||
@@ -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,9 @@ type SiteRow = {
|
|||||||
totalBalance?: number;
|
totalBalance?: number;
|
||||||
subscriptionSummary?: SiteSubscriptionSummary | null;
|
subscriptionSummary?: SiteSubscriptionSummary | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
postRefreshProbeEnabled?: boolean;
|
||||||
|
postRefreshProbeModel?: string | null;
|
||||||
|
postRefreshProbeScope?: string | null;
|
||||||
apiEndpoints?: Array<{
|
apiEndpoints?: Array<{
|
||||||
id?: number;
|
id?: number;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -288,6 +292,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 +327,13 @@ export default function Sites() {
|
|||||||
latestInitializationPresetIdRef.current = selectedInitializationPresetId;
|
latestInitializationPresetIdRef.current = selectedInitializationPresetId;
|
||||||
}, [selectedInitializationPresetId]);
|
}, [selectedInitializationPresetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) {
|
||||||
|
probeAbortRef.current?.abort();
|
||||||
|
probeAbortRef.current = null;
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||||
|
|
||||||
const brandGroups = useMemo(() => {
|
const brandGroups = useMemo(() => {
|
||||||
@@ -467,6 +489,13 @@ 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');
|
||||||
|
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 +555,150 @@ export default function Sites() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveProbeSettings = async () => {
|
||||||
|
if (!editor || editor.mode !== 'edit') return;
|
||||||
|
setProbeSaving(true);
|
||||||
|
try {
|
||||||
|
await api.updateSite(editor.editingSiteId, {
|
||||||
|
postRefreshProbeEnabled: probeEnabled,
|
||||||
|
postRefreshProbeModel: probeModel.trim(),
|
||||||
|
postRefreshProbeScope: probeScope,
|
||||||
|
});
|
||||||
|
setSites((prev) => prev.map((s) => s.id === editor.editingSiteId
|
||||||
|
? { ...s, postRefreshProbeEnabled: probeEnabled, postRefreshProbeModel: probeModel.trim(), postRefreshProbeScope: probeScope }
|
||||||
|
: s,
|
||||||
|
));
|
||||||
|
toast.success('刷新后探测设置已保存');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setProbeSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProbeNow = async () => {
|
||||||
|
if (!editor || editor.mode !== 'edit') return;
|
||||||
|
const siteId = editor.editingSiteId;
|
||||||
|
const now = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
const addLog = (text: string, color?: string) =>
|
||||||
|
setProbeLog((prev) => [...prev, { time: now(), text, color }]);
|
||||||
|
|
||||||
|
probeAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
probeAbortRef.current = controller;
|
||||||
|
setProbing(true);
|
||||||
|
setProbeLog([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken(localStorage);
|
||||||
|
const params = new URLSearchParams({ scope: probeScope });
|
||||||
|
if (probeScope === 'single' && probeModel.trim()) params.set('modelName', probeModel.trim());
|
||||||
|
const threshold = parseInt(probeLatencyThreshold, 10);
|
||||||
|
if (Number.isFinite(threshold) && threshold > 0) params.set('latencyThresholdMs', String(threshold));
|
||||||
|
|
||||||
|
const res = await fetch(`/api/sites/${siteId}/probe-stream?${params}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
let errMsg = `连接失败 (HTTP ${res.status})`;
|
||||||
|
try { const j = await res.json() as any; errMsg = j?.error || j?.message || errMsg; } catch { /* ignore */ }
|
||||||
|
addLog(errMsg, 'var(--color-error, #ef4444)');
|
||||||
|
toast.error(errMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
const handleSseEvent = (type: string, rawData: string) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(rawData);
|
||||||
|
if (type === 'start') {
|
||||||
|
addLog(`开始探测,范围:${d.scope === 'all' ? '全部模型' : '指定模型'},共 ${d.modelsCount} 个`);
|
||||||
|
} else if (type === 'model') {
|
||||||
|
const s = d.status === 'supported' ? '✓ 可用'
|
||||||
|
: d.status === 'unsupported'
|
||||||
|
? (d.latencyExceeded ? `✗ 延迟超限 (${d.latencyMs}ms)` : '✗ 不可用')
|
||||||
|
: d.status === 'skipped' ? '— 已跳过'
|
||||||
|
: '✗ 不可用';
|
||||||
|
const lat = d.latencyMs != null && d.status !== 'skipped' ? ` (${d.latencyMs}ms)` : '';
|
||||||
|
const c = d.status === 'supported' ? 'var(--color-success, #22c55e)'
|
||||||
|
: d.status === 'skipped' ? 'var(--color-text-muted)'
|
||||||
|
: 'var(--color-error, #ef4444)';
|
||||||
|
const reasonText = (() => {
|
||||||
|
if (!d.reason || d.status === 'supported' || d.status === 'skipped') return '';
|
||||||
|
const r = d.reason;
|
||||||
|
if (/timeout/i.test(r)) return '超时';
|
||||||
|
if (/missing credential|no.*token/i.test(r)) return '无 Token';
|
||||||
|
if (/no compatible.*endpoint|no.*endpoint candidate/i.test(r)) return '无可用端点';
|
||||||
|
if (/no such model|unknown model/i.test(r)) return '模型不存在';
|
||||||
|
if (/not found/i.test(r)) return '未找到';
|
||||||
|
if (/access denied|forbidden|permission/i.test(r)) return '无权限';
|
||||||
|
if (/rate.?limit|too many request/i.test(r)) return '触发频率限制';
|
||||||
|
if (/响应延迟/.test(r)) return r;
|
||||||
|
return r.length > 60 ? r.slice(0, 57) + '…' : r;
|
||||||
|
})();
|
||||||
|
addLog(`${s}${lat} ${d.modelName}${reasonText ? ` — ${reasonText}` : ''}`, c);
|
||||||
|
setTimeout(() => probeLogEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 30);
|
||||||
|
} else if (type === 'action') {
|
||||||
|
if (d.action === 'disabled') addLog(` ↳ 已加入站点禁用列表: ${d.modelName}`, 'var(--color-text-muted)');
|
||||||
|
} else if (type === 'complete') {
|
||||||
|
if (d.unsupported > 0) {
|
||||||
|
addLog(`完成:${d.probed} 个模型已探测,${d.unsupported} 个不可用已自动加入禁用列表`, 'var(--color-error, #ef4444)');
|
||||||
|
toast.error(`${d.unsupported} 个模型不可用,已自动加入站点禁用列表`);
|
||||||
|
} else {
|
||||||
|
addLog(`完成:${d.probed} 个模型均可用`, 'var(--color-success, #22c55e)');
|
||||||
|
toast.success(`探测完成:${d.probed} 个模型均可用`);
|
||||||
|
}
|
||||||
|
setTimeout(() => probeLogEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 30);
|
||||||
|
// Refresh model lists to reflect probe results
|
||||||
|
Promise.all([
|
||||||
|
api.getSiteAvailableModels(siteId).then((res: any) => {
|
||||||
|
setAvailableModels(Array.isArray(res?.models) ? res.models : []);
|
||||||
|
}),
|
||||||
|
api.getSiteDisabledModels(siteId).then((res: any) => {
|
||||||
|
setDisabledModels(Array.isArray(res?.models) ? res.models : []);
|
||||||
|
}),
|
||||||
|
]).catch(() => {}).finally(() => setProbeCompleted(true));
|
||||||
|
} else if (type === 'error') {
|
||||||
|
addLog(d.message || '探测失败', 'var(--color-error, #ef4444)');
|
||||||
|
toast.error(d.message || '探测失败');
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buf.split('\n\n');
|
||||||
|
buf = parts.pop() ?? '';
|
||||||
|
for (const part of parts) {
|
||||||
|
let eventType = 'message';
|
||||||
|
let data = '';
|
||||||
|
for (const line of part.split('\n')) {
|
||||||
|
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||||
|
else if (line.startsWith('data: ')) data = line.slice(6).trim();
|
||||||
|
}
|
||||||
|
if (data) handleSseEvent(eventType, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') {
|
||||||
|
setProbeLog((prev) => [...prev, { time: new Date().toLocaleTimeString('zh-CN', { hour12: false }), text: '已手动停止', color: 'var(--color-text-muted)' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLog(e?.message || '探测失败', 'var(--color-error, #ef4444)');
|
||||||
|
toast.error(e?.message || '探测失败');
|
||||||
|
} finally {
|
||||||
|
setProbing(false);
|
||||||
|
probeAbortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const parsedGlobalWeight = Number(form.globalWeight);
|
const parsedGlobalWeight = Number(form.globalWeight);
|
||||||
@@ -555,6 +728,9 @@ 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,
|
||||||
};
|
};
|
||||||
if (!payload.name || !payload.url) {
|
if (!payload.name || !payload.url) {
|
||||||
toast.error('请填写站点名称和 URL');
|
toast.error('请填写站点名称和 URL');
|
||||||
@@ -1485,6 +1661,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
|
||||||
|
|||||||
Reference in New Issue
Block a user