From cf6ed7db916fc9e36207c73a896554529c8f89eb Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sat, 21 Mar 2026 17:55:25 +0800 Subject: [PATCH 01/14] feat: add model filter system with site-level batch disable, global brand filter, route status filter, and channel site-block shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route status filter: add enabled/disabled filter chips to RouteFilterBar - Channel site-block: add "站点屏蔽" button on channel rows to quickly add a model to a site's disabled list and trigger rebuild - Site batch selection: new GET /api/sites/:id/available-models endpoint; Sites.tsx now shows a brand-grouped checkbox list for batch model disable - Global brand filter: new brandMatcher.ts service; settings API and UI for blocking entire brands from route generation - Fix infinite scroll race condition when sentinel ref is null during IntersectionObserver effect setup after filter changes --- src/server/config.ts | 1 + src/server/index.ts | 5 + src/server/routes/api/settings.ts | 32 +++ src/server/routes/api/sites.ts | 44 ++++ src/server/services/brandMatcher.ts | 69 ++++++ src/server/services/modelService.ts | 6 + src/web/api.ts | 2 + src/web/pages/Settings.tsx | 76 ++++++- src/web/pages/Sites.tsx | 213 ++++++++++++++---- src/web/pages/TokenRoutes.tsx | 129 ++++++++++- src/web/pages/settings.factory-reset.test.tsx | 2 + .../settings.schedule-log-cleanup.test.tsx | 2 + src/web/pages/settings.system-proxy.test.tsx | 2 + src/web/pages/sites.create-redirect.test.tsx | 1 + .../pages/sites.disabled-models-save.test.tsx | 2 + src/web/pages/sites.edit-scroll.test.tsx | 1 + src/web/pages/token-routes/RouteCard.tsx | 8 + src/web/pages/token-routes/RouteFilterBar.tsx | 39 +++- .../pages/token-routes/SortableChannelRow.tsx | 34 ++- src/web/pages/token-routes/types.ts | 2 + 20 files changed, 621 insertions(+), 49 deletions(-) create mode 100644 src/server/services/brandMatcher.ts diff --git a/src/server/config.ts b/src/server/config.ts index 2b85533..30cf5c6 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -114,6 +114,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) { proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))), proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS), proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false), + globalBlockedBrands: [] as string[], codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06', codexHeaderDefaults: { userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT), diff --git a/src/server/index.ts b/src/server/index.ts index 5eb80ad..b57da48 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -146,6 +146,11 @@ function applyRuntimeSettings(settingsMap: Map) { config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; } + const globalBlockedBrands = parseSettingFromMap(settingsMap, 'global_blocked_brands'); + if (Array.isArray(globalBlockedBrands)) { + config.globalBlockedBrands = globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + } + const codexHeaderDefaults = parseSettingFromMap(settingsMap, 'codex_header_defaults'); if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { const next = codexHeaderDefaults as Record; diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index f99f2ba..f7c5fd9 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -5,6 +5,7 @@ import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { getAllBrandNames } from '../../services/brandMatcher.js'; import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { sendNotification } from '../../services/notifyService.js'; import { @@ -71,6 +72,7 @@ interface RuntimeSettingsBody { routingWeights?: Partial; proxyErrorKeywords?: string[] | string; proxyEmptyContentFailEnabled?: boolean; + globalBlockedBrands?: string[]; } interface DatabaseMigrationBody { @@ -395,6 +397,17 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { } return; } + case 'global_blocked_brands': { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + config.globalBlockedBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean); + } + } catch { + return; + } + return; + } case 'webhook_url': { if (typeof value !== 'string') return; config.webhookUrl = value.trim(); @@ -562,6 +575,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { proxyErrorKeywords: config.proxyErrorKeywords, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyTokenMasked: maskSecret(config.proxyToken), + globalBlockedBrands: config.globalBlockedBrands, }; } @@ -639,6 +653,10 @@ export async function settingsRoutes(app: FastifyInstance) { return getRuntimeSettingsResponse(currentAdminIp); }); + app.get('/api/settings/brand-list', async () => { + return { brands: getAllBrandNames() }; + }); + app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => { const rawProxyUrl = request.body?.proxyUrl === undefined ? config.systemProxyUrl @@ -946,6 +964,20 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled); } + if (body.globalBlockedBrands !== undefined) { + const nextBrands = Array.isArray(body.globalBlockedBrands) + ? body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean) + : []; + const uniqueBrands = Array.from(new Set(nextBrands)); + const prev = JSON.stringify(config.globalBlockedBrands); + const next = JSON.stringify(uniqueBrands); + if (prev !== next) { + changedLabels.push('全局品牌屏蔽'); + } + config.globalBlockedBrands = uniqueBrands; + upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands)); + } + if (body.webhookUrl !== undefined) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { changedLabels.push('Webhook 地址'); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 294379d..0fdf23d 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -497,6 +497,50 @@ export async function sitesRoutes(app: FastifyInstance) { return { siteId: id, models: uniqueModels }; }); + // Get all discovered models for a site (from model_availability and token_model_availability) + app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => { + const id = parseInt(request.params.id); + if (Number.isNaN(id)) { + return reply.code(400).send({ error: 'Invalid site id' }); + } + const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get(); + if (!existingSite) { + return reply.code(404).send({ error: 'Site not found' }); + } + + // Get models from model_availability (account-level) + const accountModels = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .where( + and( + eq(schema.accounts.siteId, id), + eq(schema.modelAvailability.available, true), + ), + ) + .all(); + + // Get models from token_model_availability (token-level) + const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName }) + .from(schema.tokenModelAvailability) + .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) + .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) + .where( + and( + eq(schema.accounts.siteId, id), + eq(schema.tokenModelAvailability.available, true), + ), + ) + .all(); + + const models = Array.from(new Set([ + ...accountModels.map((r) => r.modelName), + ...tokenModels.map((r) => r.modelName), + ])).filter((m) => m.trim().length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + return { siteId: id, models }; + }); + // Detect platform for a URL app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => { const result = await detectSite(request.body.url); diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts new file mode 100644 index 0000000..db247d5 --- /dev/null +++ b/src/server/services/brandMatcher.ts @@ -0,0 +1,69 @@ +/** + * Server-side brand matching for global brand filtering. + * + * Brand keywords are extracted from the frontend BrandIcon.tsx BRAND_DEFINITIONS. + * Each brand maps to a set of keywords that, when found in a model name, identify + * the model as belonging to that brand. + */ + +const BRAND_KEYWORDS: Record = { + 'OpenAI': ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini', 'o1', 'o3', 'o4', 'tts'], + 'Anthropic': ['claude'], + 'Google': ['gemini', 'gemma', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa', 'veo'], + 'DeepSeek': ['deepseek', 'ds-chat'], + '通义千问': ['qwen', 'qwq', 'tongyi'], + '智谱 AI': ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo'], + 'Meta': ['llama', 'code-llama', 'codellama'], + 'Mistral': ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral'], + 'Moonshot': ['moonshot', 'kimi'], + '零一万物': ['yi-'], + '文心一言': ['ernie', 'eb-'], + '讯飞星火': ['spark', 'generalv'], + '腾讯混元': ['hunyuan', 'tencent-hunyuan'], + '豆包': ['doubao'], + 'MiniMax': ['minimax', 'abab'], + 'Cohere': ['command', 'c4ai-'], + 'Microsoft': ['phi-', 'kosmos'], + 'xAI': ['grok'], + '阶跃星辰': ['stepfun', 'step-'], + 'Stability': ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl', 'sd3'], + 'NVIDIA': ['nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'], + 'IBM': ['granite'], + 'BAAI': ['bge-'], + 'ByteDance': ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot', 'wan-', 'kat-'], + 'InternLM': ['internlm'], + 'Midjourney': ['midjourney', 'mj_'], + 'DeepL': ['deepl-', 'deepl/'], + 'Jina AI': ['jina'], +}; + +/** + * Get all available brand names. + */ +export function getAllBrandNames(): string[] { + return Object.keys(BRAND_KEYWORDS); +} + +/** + * Get the flattened list of keywords for blocked brands. + */ +export function getBlockedModelKeywords(blockedBrands: string[]): string[] { + const keywords: string[] = []; + for (const brand of blockedBrands) { + const brandKeywords = BRAND_KEYWORDS[brand]; + if (brandKeywords) { + keywords.push(...brandKeywords); + } + } + return keywords; +} + +/** + * Check if a model name matches any of the blocked keywords. + * Uses a case-insensitive substring match (same approach as frontend BrandIcon). + */ +export function isModelBlockedByBrand(modelName: string, blockedKeywords: string[]): boolean { + const lower = modelName.trim().toLowerCase(); + if (!lower) return false; + return blockedKeywords.some((keyword) => lower.includes(keyword)); +} diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 7ab8a30..87fb0ef 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -18,6 +18,8 @@ import { supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; +import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js'; +import { config } from '../config.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; @@ -1139,11 +1141,15 @@ export async function rebuildTokenRoutesFromAvailability() { return !!disabled && disabled.has(modelName); } + // Load global brand filter + const blockedBrandKeywords = getBlockedModelKeywords(config.globalBlockedBrands); + const modelCandidates = new Map>(); const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => { const modelName = (modelNameRaw || '').trim(); if (!modelName) return; if (isModelDisabledForSite(siteId, modelName)) return; + if (blockedBrandKeywords.length > 0 && isModelBlockedByBrand(modelName, blockedBrandKeywords)) return; if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map()); const candidateKey = `${accountId}:${tokenId ?? 'account'}`; modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); diff --git a/src/web/api.ts b/src/web/api.ts index 9b76f6e..ba86022 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -421,6 +421,7 @@ export const api = { detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }), getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`), updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }), + getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`), // Accounts getAccounts: () => request('/api/accounts'), @@ -570,6 +571,7 @@ export const api = { method: 'POST', body: JSON.stringify({ oldToken, newToken }), }), getRuntimeSettings: () => request('/api/settings/runtime'), + getBrandList: () => request('/api/settings/brand-list'), updateRuntimeSettings: (data: any) => request('/api/settings/runtime', { method: 'PUT', body: JSON.stringify(data), diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index a3ea17c..65950dd 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -54,6 +54,7 @@ type RuntimeSettings = { proxyTokenMasked?: string; adminIpAllowlist?: string[]; currentAdminIp?: string; + globalBlockedBrands?: string[]; }; type SystemProxyTestState = @@ -234,6 +235,9 @@ export default function Settings() { const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); + const [allBrandNames, setAllBrandNames] = useState([]); + const [blockedBrands, setBlockedBrands] = useState([]); + const [savingBrandFilter, setSavingBrandFilter] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false); const [adminIpAllowlistText, setAdminIpAllowlistText] = useState(''); const [clearingCache, setClearingCache] = useState(false); @@ -413,13 +417,15 @@ export default function Settings() { const loadSettings = async () => { setLoading(true); try { - const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([ + const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo, brandListRes] = await Promise.all([ api.getAuthInfo(), api.getRuntimeSettings(), api.getDownstreamApiKeys(), api.getRoutesLite(), api.getRuntimeDatabaseConfig(), + api.getBrandList(), ]); + setAllBrandNames(Array.isArray(brandListRes?.brands) ? brandListRes.brands : []); setMaskedToken(authInfo.masked || '****'); setRuntime({ checkinCron: runtimeInfo.checkinCron || '0 8 * * *', @@ -451,7 +457,9 @@ export default function Settings() { ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') : [], currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '', + globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [], }); + setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []); setProxyErrorKeywordsText( Array.isArray(runtimeInfo.proxyErrorKeywords) ? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n') @@ -816,6 +824,28 @@ export default function Settings() { })); }; + const handleSaveBrandFilter = async () => { + setSavingBrandFilter(true); + try { + const res = await api.updateRuntimeSettings({ globalBlockedBrands: blockedBrands }); + setRuntime((prev) => ({ + ...prev, + globalBlockedBrands: Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands, + })); + toast.success('品牌屏蔽设置已保存'); + try { + await api.rebuildRoutes(false); + toast.success('路由已重建'); + } catch { + toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建'); + } + } catch (err: any) { + toast.error(err?.message || '保存品牌屏蔽设置失败'); + } finally { + setSavingBrandFilter(false); + } + }; + const saveSecuritySettings = async () => { setSavingSecurity(true); try { @@ -1426,7 +1456,51 @@ export default function Settings() { + {/* Global Brand Filter */}
+
全局品牌屏蔽
+
+ 屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。 +
+
+ {allBrandNames.map((brand) => { + const isBlocked = blockedBrands.includes(brand); + return ( + + ); + })} + {allBrandNames.length === 0 && ( + 加载品牌列表中... + )} +
+ {blockedBrands.length > 0 && ( +
+ 已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')} +
+ )} + +
+ +
数据库迁移(SQLite / MySQL / PostgreSQL)
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。 diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index adcf76e..e54af28 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; +import { getBrand } from '../components/BrandIcon.js'; import CenteredModal from '../components/CenteredModal.js'; import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js'; @@ -222,6 +223,8 @@ export default function Sites() { const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const [disabledModelSearch, setDisabledModelSearch] = useState(''); if (editor) lastEditorRef.current = editor; const activeEditor = editor || lastEditorRef.current; @@ -326,12 +329,20 @@ export default function Sites() { setEditor({ mode: 'edit', editingSiteId: site.id }); setForm(siteFormFromSite(site)); scrollToEditorTop(); - // Load disabled models for this site + // Load disabled models and available models for this site setDisabledModelsLoading(true); setDisabledModels([]); setDisabledModelInput(''); - api.getSiteDisabledModels(site.id) - .then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : [])) + setAvailableModels([]); + setDisabledModelSearch(''); + Promise.all([ + api.getSiteDisabledModels(site.id), + api.getSiteAvailableModels(site.id), + ]) + .then(([disabledRes, availableRes]: any[]) => { + setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []); + setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []); + }) .catch(() => { }) .finally(() => setDisabledModelsLoading(false)); }; @@ -913,7 +924,7 @@ export default function Sites() {
禁用模型管理
- 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。 + 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
{disabledModelsLoading ? (
@@ -921,34 +932,155 @@ export default function Sites() {
) : ( <> -
- {disabledModels.length === 0 && ( - 暂无禁用模型 - )} - {disabledModels.map((model) => ( - - {model} - - - ))} -
+ {/* Search and brand group controls */} + {availableModels.length > 0 && ( +
+ setDisabledModelSearch(e.target.value)} + 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: 8, + }} + /> + {/* Brand group quick actions */} + {(() => { + const brandGroups = new Map(); + for (const model of availableModels) { + const brand = getBrand(model); + const brandName = brand?.name || '其他'; + if (!brandGroups.has(brandName)) brandGroups.set(brandName, []); + brandGroups.get(brandName)!.push(model); + } + const sortedBrands = [...brandGroups.entries()].sort((a, b) => { + if (a[0] === '其他') return 1; + if (b[0] === '其他') return -1; + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + }); + return ( +
+ 按品牌全选: + {sortedBrands.map(([brandName, models]) => { + const allDisabled = models.every((m) => disabledModels.includes(m)); + return ( + + ); + })} +
+ ); + })()} + {/* Checkbox list */} +
+ {(() => { + const q = disabledModelSearch.trim().toLowerCase(); + const filtered = q ? availableModels.filter((m) => m.toLowerCase().includes(q)) : availableModels; + if (filtered.length === 0) { + return
无匹配模型
; + } + // Group by brand + const brandGroups = new Map(); + for (const model of filtered) { + const brand = getBrand(model); + const brandName = brand?.name || '其他'; + if (!brandGroups.has(brandName)) brandGroups.set(brandName, []); + brandGroups.get(brandName)!.push(model); + } + const sortedBrands = [...brandGroups.entries()].sort((a, b) => { + if (a[0] === '其他') return 1; + if (b[0] === '其他') return -1; + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + }); + return sortedBrands.map(([brandName, models]) => ( +
+
+ {brandName} ({models.length}) +
+ {models.map((model) => ( + + ))} +
+ )); + })()} +
+
+ )} + + {/* Manually disabled models not in available list */} + {(() => { + const manualOnly = disabledModels.filter((m) => !availableModels.includes(m)); + if (manualOnly.length === 0) return null; + return ( +
+
手动添加的禁用模型(不在站点已发现列表中):
+
+ {manualOnly.map((model) => ( + + {model} + + + ))} +
+
+ ); + })()} + + {/* Manual input for models not in the list */}
setDisabledModelInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} @@ -962,14 +1094,19 @@ export default function Sites() { 添加
- +
+ + + 已禁用 {disabledModels.length} 个模型 + +
)}
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 6608af8..200a88f 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -54,7 +54,7 @@ import { getModelPatternError, } from './token-routes/utils.js'; import { useRouteChannels } from './token-routes/useRouteChannels.js'; -import RouteFilterBar from './token-routes/RouteFilterBar.js'; +import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js'; import ManualRoutePanel from './token-routes/ManualRoutePanel.js'; import RouteCard from './token-routes/RouteCard.js'; import AddChannelModal from './token-routes/AddChannelModal.js'; @@ -119,6 +119,7 @@ export default function TokenRoutes() { const [activeSite, setActiveSite] = useState(null); const [activeEndpointType, setActiveEndpointType] = useState(null); const [activeGroupFilter, setActiveGroupFilter] = useState(null); + const [enabledFilter, setEnabledFilter] = useState('all'); const [filterCollapsed, setFilterCollapsed] = useState(true); const [showFilters, setShowFilters] = useState(false); const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false); @@ -640,6 +641,16 @@ export default function TokenRoutes() { return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null; }, [activeGroupFilter, listVisibleRoutes]); + const enabledCounts = useMemo(() => { + let enabled = 0; + let disabled = 0; + for (const route of listVisibleRoutes) { + if (route.enabled) enabled++; + else disabled++; + } + return { enabled, disabled }; + }, [listVisibleRoutes]); + const sortedRoutes = useMemo(() => ( [...listVisibleRoutes].sort((a, b) => { if (sortBy === 'channelCount') { @@ -655,6 +666,12 @@ export default function TokenRoutes() { const filteredRoutes = useMemo(() => { let list = sortedRoutes; + if (enabledFilter === 'enabled') { + list = list.filter((route) => route.enabled); + } else if (enabledFilter === 'disabled') { + list = list.filter((route) => !route.enabled); + } + if (activeGroupFilter === '__all__') { list = list.filter((route) => !isRouteExactModel(route)); } else if (typeof activeGroupFilter === 'number') { @@ -694,6 +711,7 @@ export default function TokenRoutes() { return list; }, [ sortedRoutes, + enabledFilter, activeGroupFilter, activeBrand, activeSite, @@ -713,6 +731,8 @@ export default function TokenRoutes() { const loadMoreSentinelRef = useRef(null); + const shouldShowLoadMore = filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length; + useEffect(() => { const el = loadMoreSentinelRef.current; if (!el) return; @@ -722,7 +742,7 @@ export default function TokenRoutes() { ); observer.observe(el); return () => observer.disconnect(); - }, [handleLoadMoreRoutes]); + }, [handleLoadMoreRoutes, shouldShowLoadMore]); const visibleRoutes = useMemo( () => filteredRoutes.slice(0, visibleRouteCount), @@ -763,12 +783,45 @@ export default function TokenRoutes() { }; const handleDeleteChannel = async (channelId: number, routeId: number) => { + const dismissedKey = 'metapi:channel-delete-warning-dismissed'; + const dismissed = localStorage.getItem(dismissedKey) === 'true'; + if (!dismissed) { + const dontAskAgain = { checked: false }; + const confirmed = await new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center'; + const dialog = document.createElement('div'); + dialog.style.cssText = 'background:var(--color-bg-card,#fff);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2)'; + dialog.innerHTML = ` +
确认移除通道
+
+ 移除的通道会在定时模型刷新时被自动重建恢复。
如果只是想临时停用通道,建议使用禁用开关。 +
+ +
+ + +
+ `; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('#__ch_del_cancel')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); }); + dialog.querySelector('#__ch_del_confirm')!.addEventListener('click', () => { + dontAskAgain.checked = (dialog.querySelector('#__ch_del_dismiss') as HTMLInputElement).checked; + document.body.removeChild(overlay); + resolve(true); + }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } }); + }); + if (!confirmed) return; + if (dontAskAgain.checked) localStorage.setItem(dismissedKey, 'true'); + } try { await api.deleteChannel(channelId); toast.success('通道已移除'); - // Reload channels for this route await loadChannels(routeId, true); - // Update channel count in summary setRouteSummaries((prev) => prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r), ); @@ -777,6 +830,16 @@ export default function TokenRoutes() { } }; + const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => { + try { + await api.updateChannel(channelId, { enabled }); + toast.success(enabled ? '通道已启用' : '通道已禁用'); + await loadChannels(routeId, true); + } catch (e: any) { + toast.error(e.message || '更新通道状态失败'); + } + }; + const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => { const tokenId = channelTokenDraft[channelId]; const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || []; @@ -847,6 +910,42 @@ export default function TokenRoutes() { } }; + const handleSiteBlockModel = async (channelId: number, routeId: number) => { + const channels = channelsByRouteId[routeId] || []; + const channel = channels.find((c) => c.id === channelId); + if (!channel?.site?.id) { + toast.error('找不到通道对应的站点信息'); + return; + } + const route = routeSummaries.find((r) => r.id === routeId); + const modelName = channel.sourceModel || route?.modelPattern || ''; + if (!modelName) { + toast.error('找不到要屏蔽的模型名'); + return; + } + const siteName = channel.site.name || '未知站点'; + const confirmed = window.confirm( + `确定将模型「${modelName}」加入站点「${siteName}」的禁用列表吗?\n\n执行后将自动触发路由重建,该站点下此模型的通道将不再生成。`, + ); + if (!confirmed) return; + + try { + const siteId = channel.site.id; + const existing = await api.getSiteDisabledModels(siteId); + const currentModels: string[] = existing?.models || []; + if (currentModels.includes(modelName)) { + toast.info(`模型「${modelName}」已在站点「${siteName}」的禁用列表中`); + return; + } + await api.updateSiteDisabledModels(siteId, [...currentModels, modelName]); + toast.success(`已将「${modelName}」加入站点「${siteName}」的禁用列表,正在重建路由...`); + await api.rebuildRoutes(false); + await load(); + } catch (e: any) { + toast.error(e.message || '站点屏蔽模型失败'); + } + }; + const toggleExpand = async (routeId: number) => { const isCurrentlyExpanded = expandedRouteIds.includes(routeId); if (isCurrentlyExpanded) { @@ -985,6 +1084,12 @@ export default function TokenRoutes() { (channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId), [], ); + const handleToggleChannelEnabledRef = useRef(handleToggleChannelEnabled); + handleToggleChannelEnabledRef.current = handleToggleChannelEnabled; + const stableToggleChannelEnabled = useCallback( + (channelId: number, routeId: number, enabled: boolean) => handleToggleChannelEnabledRef.current(channelId, routeId, enabled), + [], + ); const handleChannelDragEndRef = useRef(handleChannelDragEnd); handleChannelDragEndRef.current = handleChannelDragEnd; const stableChannelDragEnd = useCallback( @@ -997,6 +1102,12 @@ export default function TokenRoutes() { (accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName), [], ); + const handleSiteBlockModelRef = useRef(handleSiteBlockModel); + handleSiteBlockModelRef.current = handleSiteBlockModel; + const stableSiteBlockModel = useCallback( + (channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId), + [], + ); const addChannelModalRoute = addChannelModalRouteId ? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null @@ -1133,6 +1244,9 @@ export default function TokenRoutes() { setActiveEndpointType={setActiveEndpointType} activeGroupFilter={activeGroupFilter} setActiveGroupFilter={setActiveGroupFilter} + enabledFilter={enabledFilter} + setEnabledFilter={setEnabledFilter} + enabledCounts={enabledCounts} brandList={brandList} siteList={siteList} endpointTypeList={endpointTypeList} @@ -1153,6 +1267,9 @@ export default function TokenRoutes() { setActiveEndpointType={setActiveEndpointType} activeGroupFilter={activeGroupFilter} setActiveGroupFilter={setActiveGroupFilter} + enabledFilter={enabledFilter} + setEnabledFilter={setEnabledFilter} + enabledCounts={enabledCounts} brandList={brandList} siteList={siteList} endpointTypeList={endpointTypeList} @@ -1311,11 +1428,13 @@ export default function TokenRoutes() { onTokenDraftChange={stableTokenDraftChange} onSaveToken={stableChannelTokenSave} onDeleteChannel={stableDeleteChannel} + onToggleChannelEnabled={stableToggleChannelEnabled} onChannelDragEnd={stableChannelDragEnd} missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} + onSiteBlockModel={stableSiteBlockModel} expandedSourceGroupMap={expandedSourceGroupMap} onToggleSourceGroup={stableToggleSourceGroup} /> @@ -1323,7 +1442,7 @@ export default function TokenRoutes() { })}
- {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( + {shouldShowLoadMore && (
({ getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), + getBrandList: vi.fn(), factoryReset: vi.fn(), }, })); @@ -77,6 +78,7 @@ describe('Settings factory reset', () => { }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getRoutesLite.mockResolvedValue([]); + apiMock.getBrandList.mockResolvedValue({ brands: [] }); apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, saved: null, diff --git a/src/web/pages/settings.schedule-log-cleanup.test.tsx b/src/web/pages/settings.schedule-log-cleanup.test.tsx index d9e8aee..b64112d 100644 --- a/src/web/pages/settings.schedule-log-cleanup.test.tsx +++ b/src/web/pages/settings.schedule-log-cleanup.test.tsx @@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({ getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), + getBrandList: vi.fn(), updateRuntimeSettings: vi.fn(), triggerCheckinAll: vi.fn(), }, @@ -62,6 +63,7 @@ describe('Settings log cleanup schedule', () => { }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getRoutesLite.mockResolvedValue([]); + apiMock.getBrandList.mockResolvedValue({ brands: [] }); apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, saved: null, diff --git a/src/web/pages/settings.system-proxy.test.tsx b/src/web/pages/settings.system-proxy.test.tsx index fab13e5..c899d41 100644 --- a/src/web/pages/settings.system-proxy.test.tsx +++ b/src/web/pages/settings.system-proxy.test.tsx @@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({ getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getRuntimeDatabaseConfig: vi.fn(), + getBrandList: vi.fn(), updateRuntimeSettings: vi.fn(), testSystemProxy: vi.fn(), }, @@ -59,6 +60,7 @@ describe('Settings system proxy', () => { }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getRoutesLite.mockResolvedValue([]); + apiMock.getBrandList.mockResolvedValue({ brands: [] }); apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, saved: null, diff --git a/src/web/pages/sites.create-redirect.test.tsx b/src/web/pages/sites.create-redirect.test.tsx index a8f488d..ff3774f 100644 --- a/src/web/pages/sites.create-redirect.test.tsx +++ b/src/web/pages/sites.create-redirect.test.tsx @@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({ getSites: vi.fn(), addSite: vi.fn(), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), + getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }), }, })); diff --git a/src/web/pages/sites.disabled-models-save.test.tsx b/src/web/pages/sites.disabled-models-save.test.tsx index 4cb7757..175e8a8 100644 --- a/src/web/pages/sites.disabled-models-save.test.tsx +++ b/src/web/pages/sites.disabled-models-save.test.tsx @@ -9,6 +9,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({ apiMock: { getSites: vi.fn(), getSiteDisabledModels: vi.fn(), + getSiteAvailableModels: vi.fn(), updateSiteDisabledModels: vi.fn(), rebuildRoutes: vi.fn(), }, @@ -57,6 +58,7 @@ describe('Sites disabled models save', () => { }, ]); apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] }); + apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] }); apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true }); }); diff --git a/src/web/pages/sites.edit-scroll.test.tsx b/src/web/pages/sites.edit-scroll.test.tsx index 6b2c723..dc87e90 100644 --- a/src/web/pages/sites.edit-scroll.test.tsx +++ b/src/web/pages/sites.edit-scroll.test.tsx @@ -8,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({ apiMock: { getSites: vi.fn(), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), + getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }), }, })); diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index 06eaa99..3564e26 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -61,6 +61,7 @@ type RouteCardProps = { onTokenDraftChange: (channelId: number, tokenId: number) => void; onSaveToken: (routeId: number, channelId: number, accountId: number) => void; onDeleteChannel: (channelId: number, routeId: number) => void; + onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void; onChannelDragEnd: (routeId: number, event: DragEndEvent) => void; // Missing token hints missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; @@ -68,6 +69,8 @@ type RouteCardProps = { onCreateTokenForMissing: (accountId: number, modelName: string) => void; // Add channel onAddChannel: (routeId: number) => void; + // Site block model + onSiteBlockModel: (channelId: number, routeId: number) => void; // Source group expansion expandedSourceGroupMap: Record; onToggleSourceGroup: (groupKey: string) => void; @@ -119,11 +122,13 @@ function RouteCardInner({ onTokenDraftChange, onSaveToken, onDeleteChannel, + onToggleChannelEnabled, onChannelDragEnd, missingTokenSiteItems, missingTokenGroupItems, onCreateTokenForMissing, onAddChannel, + onSiteBlockModel, expandedSourceGroupMap, onToggleSourceGroup, }: RouteCardProps) { @@ -548,6 +553,7 @@ function RouteCardInner({ onTokenDraftChange={onTokenDraftChange} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)} /> ); })} @@ -572,6 +578,8 @@ function RouteCardInner({ onTokenDraftChange={onTokenDraftChange} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)} + onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)} /> ); })} diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx index edc3824..8285bd8 100644 --- a/src/web/pages/token-routes/RouteFilterBar.tsx +++ b/src/web/pages/token-routes/RouteFilterBar.tsx @@ -5,6 +5,8 @@ import { tr } from '../../i18n.js'; import type { GroupFilter, GroupRouteItem } from './types.js'; import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js'; +export type EnabledFilter = 'all' | 'enabled' | 'disabled'; + type RouteFilterBarProps = { totalRouteCount: number; activeBrand: string | null; @@ -15,6 +17,9 @@ type RouteFilterBarProps = { setActiveEndpointType: (endpointType: string | null) => void; activeGroupFilter: GroupFilter; setActiveGroupFilter: (filter: GroupFilter) => void; + enabledFilter: EnabledFilter; + setEnabledFilter: (filter: EnabledFilter) => void; + enabledCounts: { enabled: number; disabled: number }; brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number }; siteList: [string, { count: number; siteId: number }][]; endpointTypeList: [string, number][]; @@ -63,8 +68,11 @@ function ActiveFilterSummary({ activeSite, activeGroupFilter, activeEndpointType, -}: Pick) { + enabledFilter, +}: Pick) { const tags: string[] = []; + if (enabledFilter === 'enabled') tags.push('状态=启用'); + else if (enabledFilter === 'disabled') tags.push('状态=禁用'); if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`); if (activeSite) tags.push(`站点=${activeSite}`); if (activeGroupFilter === '__all__') tags.push('群组=全部'); @@ -86,6 +94,9 @@ export default function RouteFilterBar(props: RouteFilterBarProps) { setActiveEndpointType, activeGroupFilter, setActiveGroupFilter, + enabledFilter, + setEnabledFilter, + enabledCounts, brandList, siteList, endpointTypeList, @@ -117,12 +128,38 @@ export default function RouteFilterBar(props: RouteFilterBarProps) { activeSite={activeSite} activeGroupFilter={activeGroupFilter} activeEndpointType={activeEndpointType} + enabledFilter={enabledFilter} /> {/* Expanded panel */} {expandPresence.shouldRender && (
+ {/* Status row */} + + ✦} + onClick={() => setEnabledFilter('all')} + /> + ●} + onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')} + /> + ●} + onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')} + /> + + {/* Brand row */} ) : null} + {channel.enabled === false ? ( + 已禁用 + ) : null} +
选中概率
@@ -502,11 +508,31 @@ export function SortableChannelRow({
+ +
+ {onSiteBlockModel && channel.site?.id ? ( + + ) : null} + + +
) ) : null} diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 6474033..c0a7197 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -161,6 +161,8 @@ export type SortableChannelRowProps = { onTokenDraftChange: (channelId: number, tokenId: number) => void; onSaveToken: () => void; onDeleteChannel: () => void; + onToggleEnabled: (enabled: boolean) => void; + onSiteBlockModel?: () => void; }; export type GroupRouteItem = { From f14ffd7dc912dd2f22645da7d7b28be751dfb991 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 00:13:41 +0800 Subject: [PATCH 02/14] fix: address review issues in model filter system - brandMatcher: split keywords into includes/startsWith/segment/boundary modes to align with frontend BrandIcon.tsx matching semantics, preventing false-positive blocking from short keywords like o1, tts, command - handleSiteBlockModel: reject wildcard route patterns (e.g. gpt-*) from being written to site disabled list; only allow exact model names - Sites.tsx: extract brand grouping to useMemo, convert disabledModels to Set for O(1) lookup, remove duplicate IIFE computations in JSX - handleSiteBlockModel: replace window.confirm with project-style DOM dialog for visual consistency - enabledCounts: compute from filtered scope (excluding enabled filter itself) so counts reflect current brand/site/search filters - Fix declaration order: move enabledCounts after sortedRoutes to avoid ReferenceError --- src/server/services/brandMatcher.ts | 267 +++++++++++++++++++++++----- src/server/services/modelService.ts | 6 +- src/web/pages/Sites.tsx | 144 ++++++++------- src/web/pages/TokenRoutes.tsx | 79 ++++++-- 4 files changed, 358 insertions(+), 138 deletions(-) diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts index db247d5..c0606d9 100644 --- a/src/server/services/brandMatcher.ts +++ b/src/server/services/brandMatcher.ts @@ -1,69 +1,244 @@ /** * Server-side brand matching for global brand filtering. * - * Brand keywords are extracted from the frontend BrandIcon.tsx BRAND_DEFINITIONS. - * Each brand maps to a set of keywords that, when found in a model name, identify - * the model as belonging to that brand. + * Matching semantics are aligned with the frontend BrandIcon.tsx BRAND_DEFINITIONS: + * - `includes`: substring match (e.g. 'claude' matches 'claude-3-opus') + * - `startsWith`: segment-level startsWith (e.g. 'gpt' matches 'gpt-4o' but not 'some-gpt') + * - `segment`: exact segment match after splitting on /:-_ delimiters + * - `boundary`: word-boundary regex match */ -const BRAND_KEYWORDS: Record = { - 'OpenAI': ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini', 'o1', 'o3', 'o4', 'tts'], - 'Anthropic': ['claude'], - 'Google': ['gemini', 'gemma', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa', 'veo'], - 'DeepSeek': ['deepseek', 'ds-chat'], - '通义千问': ['qwen', 'qwq', 'tongyi'], - '智谱 AI': ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo'], - 'Meta': ['llama', 'code-llama', 'codellama'], - 'Mistral': ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral'], - 'Moonshot': ['moonshot', 'kimi'], - '零一万物': ['yi-'], - '文心一言': ['ernie', 'eb-'], - '讯飞星火': ['spark', 'generalv'], - '腾讯混元': ['hunyuan', 'tencent-hunyuan'], - '豆包': ['doubao'], - 'MiniMax': ['minimax', 'abab'], - 'Cohere': ['command', 'c4ai-'], - 'Microsoft': ['phi-', 'kosmos'], - 'xAI': ['grok'], - '阶跃星辰': ['stepfun', 'step-'], - 'Stability': ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl', 'sd3'], - 'NVIDIA': ['nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'], - 'IBM': ['granite'], - 'BAAI': ['bge-'], - 'ByteDance': ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot', 'wan-', 'kat-'], - 'InternLM': ['internlm'], - 'Midjourney': ['midjourney', 'mj_'], - 'DeepL': ['deepl-', 'deepl/'], - 'Jina AI': ['jina'], +type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; +type KeywordRule = { keyword: string; mode: MatchMode }; + +const BRAND_RULES: Record = { + // OpenAI: all startsWithAny in frontend + 'OpenAI': [ + { keyword: 'gpt', mode: 'startsWith' }, + { keyword: 'chatgpt', mode: 'startsWith' }, + { keyword: 'dall-e', mode: 'startsWith' }, + { keyword: 'whisper', mode: 'startsWith' }, + { keyword: 'text-embedding', mode: 'startsWith' }, + { keyword: 'text-moderation', mode: 'startsWith' }, + { keyword: 'davinci', mode: 'startsWith' }, + { keyword: 'babbage', mode: 'startsWith' }, + { keyword: 'codex-mini', mode: 'startsWith' }, + { keyword: 'o1', mode: 'startsWith' }, + { keyword: 'o3', mode: 'startsWith' }, + { keyword: 'o4', mode: 'startsWith' }, + { keyword: 'tts', mode: 'startsWith' }, + ], + // Anthropic: includesAny + 'Anthropic': [ + { keyword: 'claude', mode: 'includes' }, + ], + // Google: includesAny + startsWithAny for veo/google/ + 'Google': [ + { keyword: 'gemini', mode: 'includes' }, + { keyword: 'gemma', mode: 'includes' }, + { keyword: 'google/', mode: 'includes' }, + { keyword: 'palm', mode: 'includes' }, + { keyword: 'paligemma', mode: 'includes' }, + { keyword: 'shieldgemma', mode: 'includes' }, + { keyword: 'recurrentgemma', mode: 'includes' }, + { keyword: 'deplot', mode: 'includes' }, + { keyword: 'codegemma', mode: 'includes' }, + { keyword: 'imagen', mode: 'includes' }, + { keyword: 'learnlm', mode: 'includes' }, + { keyword: 'aqa', mode: 'includes' }, + { keyword: 'veo', mode: 'startsWith' }, + ], + // DeepSeek: includesAny + hasExactSegment + 'DeepSeek': [ + { keyword: 'deepseek', mode: 'includes' }, + { keyword: 'ds-chat', mode: 'segment' }, + ], + '通义千问': [ + { keyword: 'qwen', mode: 'includes' }, + { keyword: 'qwq', mode: 'includes' }, + { keyword: 'tongyi', mode: 'includes' }, + ], + '智谱 AI': [ + { keyword: 'glm', mode: 'includes' }, + { keyword: 'chatglm', mode: 'includes' }, + { keyword: 'codegeex', mode: 'includes' }, + { keyword: 'cogview', mode: 'includes' }, + { keyword: 'cogvideo', mode: 'includes' }, + ], + 'Meta': [ + { keyword: 'llama', mode: 'includes' }, + { keyword: 'code-llama', mode: 'includes' }, + { keyword: 'codellama', mode: 'includes' }, + ], + 'Mistral': [ + { keyword: 'mistral', mode: 'includes' }, + { keyword: 'mixtral', mode: 'includes' }, + { keyword: 'codestral', mode: 'includes' }, + { keyword: 'pixtral', mode: 'includes' }, + { keyword: 'ministral', mode: 'includes' }, + { keyword: 'voxtral', mode: 'includes' }, + { keyword: 'magistral', mode: 'includes' }, + ], + 'Moonshot': [ + { keyword: 'moonshot', mode: 'includes' }, + { keyword: 'kimi', mode: 'includes' }, + ], + // 零一万物: startsWithAny('yi-') + matchesBoundary + '零一万物': [ + { keyword: 'yi-', mode: 'startsWith' }, + { keyword: 'yi', mode: 'boundary' }, + ], + '文心一言': [ + { keyword: 'ernie', mode: 'includes' }, + { keyword: 'eb-', mode: 'includes' }, + ], + '讯飞星火': [ + { keyword: 'spark', mode: 'includes' }, + { keyword: 'generalv', mode: 'includes' }, + ], + '腾讯混元': [ + { keyword: 'hunyuan', mode: 'includes' }, + { keyword: 'tencent-hunyuan', mode: 'includes' }, + ], + '豆包': [ + { keyword: 'doubao', mode: 'includes' }, + ], + // MiniMax: includesAny + hasExactSegment + 'MiniMax': [ + { keyword: 'minimax', mode: 'includes' }, + { keyword: 'abab', mode: 'includes' }, + { keyword: 'mini2.1', mode: 'segment' }, + ], + // Cohere: includesAny + startsWithAny('embed-') + 'Cohere': [ + { keyword: 'command', mode: 'includes' }, + { keyword: 'c4ai-', mode: 'includes' }, + { keyword: 'embed-', mode: 'startsWith' }, + ], + // Microsoft: includesAny + hasExactSegment('phi4') + 'Microsoft': [ + { keyword: 'microsoft/', mode: 'includes' }, + { keyword: 'phi-', mode: 'includes' }, + { keyword: 'kosmos', mode: 'includes' }, + { keyword: 'phi4', mode: 'segment' }, + ], + 'xAI': [ + { keyword: 'grok', mode: 'includes' }, + ], + // 阶跃星辰: includesAny('stepfun') + startsWithAny('step-', 'step3') + '阶跃星辰': [ + { keyword: 'stepfun', mode: 'includes' }, + { keyword: 'step-', mode: 'startsWith' }, + { keyword: 'step3', mode: 'startsWith' }, + ], + // Stability: includesAny + startsWithAny('sd3') + 'Stability': [ + { keyword: 'flux', mode: 'includes' }, + { keyword: 'stablediffusion', mode: 'includes' }, + { keyword: 'stable-diffusion', mode: 'includes' }, + { keyword: 'sdxl', mode: 'includes' }, + { keyword: 'sd3', mode: 'startsWith' }, + ], + // NVIDIA: includesAny + startsWithAny('nv-') + 'NVIDIA': [ + { keyword: 'nvidia/', mode: 'includes' }, + { keyword: 'nvclip', mode: 'includes' }, + { keyword: 'nemotron', mode: 'includes' }, + { keyword: 'nemoretriever', mode: 'includes' }, + { keyword: 'neva', mode: 'includes' }, + { keyword: 'riva-translate', mode: 'includes' }, + { keyword: 'cosmos', mode: 'includes' }, + { keyword: 'nv-', mode: 'startsWith' }, + ], + 'IBM': [ + { keyword: 'ibm/', mode: 'includes' }, + { keyword: 'granite', mode: 'includes' }, + ], + 'BAAI': [ + { keyword: 'baai/', mode: 'includes' }, + { keyword: 'bge-', mode: 'includes' }, + ], + // ByteDance: includesAny + startsWithAny('wan-', 'kat-') + 'ByteDance': [ + { keyword: 'bytedance', mode: 'includes' }, + { keyword: 'seed-oss', mode: 'includes' }, + { keyword: 'kolors', mode: 'includes' }, + { keyword: 'kwai', mode: 'includes' }, + { keyword: 'kwaipilot', mode: 'includes' }, + { keyword: 'wan-', mode: 'startsWith' }, + { keyword: 'kat-', mode: 'startsWith' }, + ], + 'InternLM': [ + { keyword: 'internlm', mode: 'includes' }, + ], + // Midjourney: includesAny + startsWithAny('mj_') + 'Midjourney': [ + { keyword: 'midjourney', mode: 'includes' }, + { keyword: 'mj_', mode: 'startsWith' }, + ], + // DeepL: startsWithAny('deepl-') + includesAny('deepl/') + 'DeepL': [ + { keyword: 'deepl/', mode: 'includes' }, + { keyword: 'deepl-', mode: 'startsWith' }, + ], + 'Jina AI': [ + { keyword: 'jina', mode: 'includes' }, + ], }; /** * Get all available brand names. */ export function getAllBrandNames(): string[] { - return Object.keys(BRAND_KEYWORDS); + return Object.keys(BRAND_RULES); } /** - * Get the flattened list of keywords for blocked brands. + * Collect the rules for the blocked brands. */ -export function getBlockedModelKeywords(blockedBrands: string[]): string[] { - const keywords: string[] = []; +export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { + const rules: KeywordRule[] = []; for (const brand of blockedBrands) { - const brandKeywords = BRAND_KEYWORDS[brand]; - if (brandKeywords) { - keywords.push(...brandKeywords); - } + const brandRules = BRAND_RULES[brand]; + if (brandRules) rules.push(...brandRules); } - return keywords; + return rules; } /** - * Check if a model name matches any of the blocked keywords. - * Uses a case-insensitive substring match (same approach as frontend BrandIcon). + * Split model name into segments (matching the frontend's segment logic). */ -export function isModelBlockedByBrand(modelName: string, blockedKeywords: string[]): boolean { +function getSegments(lower: string): string[] { + return lower.split(/[/:,\s_\-]+/g).filter(Boolean); +} + +/** + * Check if a model name matches a single keyword rule. + */ +function matchesRule(lower: string, segments: string[], rule: KeywordRule): boolean { + switch (rule.mode) { + case 'includes': + return lower.includes(rule.keyword); + case 'startsWith': + // Match if any segment starts with the keyword, or the full name starts with it + return lower.startsWith(rule.keyword) || segments.some((seg) => seg.startsWith(rule.keyword)); + case 'segment': + return segments.includes(rule.keyword); + case 'boundary': { + const pattern = new RegExp(`(^|[^a-z0-9])${rule.keyword}(?=$|[^a-z0-9])`); + return pattern.test(lower); + } + default: + return false; + } +} + +/** + * Check if a model name matches any of the blocked brand rules. + */ +export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean { const lower = modelName.trim().toLowerCase(); - if (!lower) return false; - return blockedKeywords.some((keyword) => lower.includes(keyword)); + if (!lower || rules.length === 0) return false; + const segments = getSegments(lower); + return rules.some((rule) => matchesRule(lower, segments, rule)); } diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 87fb0ef..667c8d1 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -18,7 +18,7 @@ import { supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; -import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js'; +import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js'; import { config } from '../config.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; @@ -1142,14 +1142,14 @@ export async function rebuildTokenRoutesFromAvailability() { } // Load global brand filter - const blockedBrandKeywords = getBlockedModelKeywords(config.globalBlockedBrands); + const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands); const modelCandidates = new Map>(); const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => { const modelName = (modelNameRaw || '').trim(); if (!modelName) return; if (isModelDisabledForSite(siteId, modelName)) return; - if (blockedBrandKeywords.length > 0 && isModelBlockedByBrand(modelName, blockedBrandKeywords)) return; + if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return; if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map()); const candidateKey = `${accountId}:${tokenId ?? 'account'}`; modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index e54af28..4c24a99 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -226,6 +226,31 @@ export default function Sites() { const [availableModels, setAvailableModels] = useState([]); const [disabledModelSearch, setDisabledModelSearch] = useState(''); + const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]); + + const brandGroups = useMemo(() => { + const groups = new Map(); + for (const model of availableModels) { + const brand = getBrand(model); + const brandName = brand?.name || '其他'; + if (!groups.has(brandName)) groups.set(brandName, []); + groups.get(brandName)!.push(model); + } + return [...groups.entries()].sort((a, b) => { + if (a[0] === '其他') return 1; + if (b[0] === '其他') return -1; + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + }); + }, [availableModels]); + + const filteredBrandGroups = useMemo(() => { + const q = disabledModelSearch.trim().toLowerCase(); + if (!q) return brandGroups; + return brandGroups + .map(([brandName, models]) => [brandName, models.filter((m) => m.toLowerCase().includes(q))] as [string, string[]]) + .filter(([, models]) => models.length > 0); + }, [brandGroups, disabledModelSearch]); + if (editor) lastEditorRef.current = editor; const activeEditor = editor || lastEditorRef.current; const isEditing = activeEditor?.mode === 'edit'; @@ -350,7 +375,7 @@ export default function Sites() { const handleAddDisabledModel = () => { const model = disabledModelInput.trim(); if (!model) return; - if (disabledModels.includes(model)) { + if (disabledModelSet.has(model)) { toast.info(`模型 "${model}" 已在禁用列表中`); setDisabledModelInput(''); return; @@ -946,84 +971,54 @@ export default function Sites() { }} /> {/* Brand group quick actions */} - {(() => { - const brandGroups = new Map(); - for (const model of availableModels) { - const brand = getBrand(model); - const brandName = brand?.name || '其他'; - if (!brandGroups.has(brandName)) brandGroups.set(brandName, []); - brandGroups.get(brandName)!.push(model); - } - const sortedBrands = [...brandGroups.entries()].sort((a, b) => { - if (a[0] === '其他') return 1; - if (b[0] === '其他') return -1; - return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); - }); - return ( -
- 按品牌全选: - {sortedBrands.map(([brandName, models]) => { - const allDisabled = models.every((m) => disabledModels.includes(m)); - return ( - - ); - })} -
- ); - })()} +
+ 按品牌全选: + {brandGroups.map(([brandName, models]) => { + const allDisabled = models.every((m) => disabledModelSet.has(m)); + return ( + + ); + })} +
{/* Checkbox list */}
- {(() => { - const q = disabledModelSearch.trim().toLowerCase(); - const filtered = q ? availableModels.filter((m) => m.toLowerCase().includes(q)) : availableModels; - if (filtered.length === 0) { - return
无匹配模型
; - } - // Group by brand - const brandGroups = new Map(); - for (const model of filtered) { - const brand = getBrand(model); - const brandName = brand?.name || '其他'; - if (!brandGroups.has(brandName)) brandGroups.set(brandName, []); - brandGroups.get(brandName)!.push(model); - } - const sortedBrands = [...brandGroups.entries()].sort((a, b) => { - if (a[0] === '其他') return 1; - if (b[0] === '其他') return -1; - return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); - }); - return sortedBrands.map(([brandName, models]) => ( -
-
- {brandName} ({models.length}) -
- {models.map((model) => ( + {filteredBrandGroups.length === 0 ? ( +
无匹配模型
+ ) : filteredBrandGroups.map(([brandName, models]) => ( +
+
+ {brandName} ({models.length}) +
+ {models.map((model) => { + const isDisabled = disabledModelSet.has(model); + return ( - ))} -
- )); - })()} + ); + })} +
+ ))}
)} {/* Manually disabled models not in available list */} {(() => { - const manualOnly = disabledModels.filter((m) => !availableModels.includes(m)); + const availableSet = new Set(availableModels); + const manualOnly = disabledModels.filter((m) => !availableSet.has(m)); if (manualOnly.length === 0) return null; return (
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 200a88f..d118116 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -641,16 +641,6 @@ export default function TokenRoutes() { return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null; }, [activeGroupFilter, listVisibleRoutes]); - const enabledCounts = useMemo(() => { - let enabled = 0; - let disabled = 0; - for (const route of listVisibleRoutes) { - if (route.enabled) enabled++; - else disabled++; - } - return { enabled, disabled }; - }, [listVisibleRoutes]); - const sortedRoutes = useMemo(() => ( [...listVisibleRoutes].sort((a, b) => { if (sortBy === 'channelCount') { @@ -663,6 +653,47 @@ export default function TokenRoutes() { }) ), [listVisibleRoutes, sortBy, sortDir]); + const enabledCounts = useMemo(() => { + // Count enabled/disabled within the currently filtered scope (excluding the enabled filter itself) + let base = sortedRoutes; + + if (activeGroupFilter === '__all__') { + base = base.filter((route) => !isRouteExactModel(route)); + } else if (typeof activeGroupFilter === 'number') { + base = base.filter((route) => route.id === activeGroupFilter); + } + if (activeBrand) { + if (activeBrand === '__other__') { + base = base.filter((route) => !(routeBrandById.get(route.id) || null)); + } else { + base = base.filter((route) => (routeBrandById.get(route.id)?.name || '') === activeBrand); + } + } + if (activeSite) { + base = base.filter((route) => route.siteNames?.includes(activeSite)); + } + if (activeEndpointType) { + base = base.filter((route) => (routeEndpointTypesByRouteId[route.id] || new Set()).has(activeEndpointType)); + } + if (search.trim()) { + const q = search.trim().toLowerCase(); + base = base.filter((route) => { + const modelPattern = route.modelPattern.toLowerCase(); + const displayName = (route.displayName || '').toLowerCase(); + const title = resolveRouteTitle(route).toLowerCase(); + return modelPattern.includes(q) || displayName.includes(q) || title.includes(q); + }); + } + + let enabled = 0; + let disabled = 0; + for (const route of base) { + if (route.enabled) enabled++; + else disabled++; + } + return { enabled, disabled }; + }, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]); + const filteredRoutes = useMemo(() => { let list = sortedRoutes; @@ -918,15 +949,33 @@ export default function TokenRoutes() { return; } const route = routeSummaries.find((r) => r.id === routeId); - const modelName = channel.sourceModel || route?.modelPattern || ''; + const modelName = channel.sourceModel || (route && isExactModelPattern(route.modelPattern) ? route.modelPattern : '') || ''; if (!modelName) { - toast.error('找不到要屏蔽的模型名'); + toast.error('该通道没有精确模型名,无法使用站点屏蔽(通配符路由请在站点编辑中手动禁用)'); return; } const siteName = channel.site.name || '未知站点'; - const confirmed = window.confirm( - `确定将模型「${modelName}」加入站点「${siteName}」的禁用列表吗?\n\n执行后将自动触发路由重建,该站点下此模型的通道将不再生成。`, - ); + const confirmed = await new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center'; + const dialog = document.createElement('div'); + dialog.style.cssText = 'background:var(--color-bg-card,#fff);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2)'; + dialog.innerHTML = ` +
确认站点屏蔽
+
+ 将模型「${modelName}」加入站点「${siteName}」的禁用列表。
执行后将自动触发路由重建,该站点下此模型的通道将不再生成。 +
+
+ + +
+ `; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('#__sb_cancel')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); }); + dialog.querySelector('#__sb_confirm')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(true); }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } }); + }); if (!confirmed) return; try { From 4415317bdca2fe75de513b139602f18e6935d034 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 00:51:45 +0800 Subject: [PATCH 03/14] fix: align brand matcher with frontend logic and remove manual model input - brandMatcher: port collectBrandCandidates + buildMatchContext from frontend BrandIcon.tsx so matching is identical between route display and rebuild filtering; segments now split on /,:,\s only (not - or _), and candidates are recursively generated from /,:,, splits - Sites.tsx: remove manual model name input and handleAddDisabledModel; the checkbox-based batch selection UI fully replaces it --- src/server/services/brandMatcher.ts | 145 +++++++++++++++++----------- src/web/pages/Sites.tsx | 69 ------------- 2 files changed, 87 insertions(+), 127 deletions(-) diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts index c0606d9..efca07d 100644 --- a/src/server/services/brandMatcher.ts +++ b/src/server/services/brandMatcher.ts @@ -1,18 +1,15 @@ /** * Server-side brand matching for global brand filtering. * - * Matching semantics are aligned with the frontend BrandIcon.tsx BRAND_DEFINITIONS: - * - `includes`: substring match (e.g. 'claude' matches 'claude-3-opus') - * - `startsWith`: segment-level startsWith (e.g. 'gpt' matches 'gpt-4o' but not 'some-gpt') - * - `segment`: exact segment match after splitting on /:-_ delimiters - * - `boundary`: word-boundary regex match + * Context-building logic is ported from the frontend BrandIcon.tsx + * (collectBrandCandidates + buildMatchContext) to ensure identical + * matching behavior between route display and rebuild filtering. */ type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; type KeywordRule = { keyword: string; mode: MatchMode }; const BRAND_RULES: Record = { - // OpenAI: all startsWithAny in frontend 'OpenAI': [ { keyword: 'gpt', mode: 'startsWith' }, { keyword: 'chatgpt', mode: 'startsWith' }, @@ -28,11 +25,9 @@ const BRAND_RULES: Record = { { keyword: 'o4', mode: 'startsWith' }, { keyword: 'tts', mode: 'startsWith' }, ], - // Anthropic: includesAny 'Anthropic': [ { keyword: 'claude', mode: 'includes' }, ], - // Google: includesAny + startsWithAny for veo/google/ 'Google': [ { keyword: 'gemini', mode: 'includes' }, { keyword: 'gemma', mode: 'includes' }, @@ -48,7 +43,6 @@ const BRAND_RULES: Record = { { keyword: 'aqa', mode: 'includes' }, { keyword: 'veo', mode: 'startsWith' }, ], - // DeepSeek: includesAny + hasExactSegment 'DeepSeek': [ { keyword: 'deepseek', mode: 'includes' }, { keyword: 'ds-chat', mode: 'segment' }, @@ -83,7 +77,6 @@ const BRAND_RULES: Record = { { keyword: 'moonshot', mode: 'includes' }, { keyword: 'kimi', mode: 'includes' }, ], - // 零一万物: startsWithAny('yi-') + matchesBoundary '零一万物': [ { keyword: 'yi-', mode: 'startsWith' }, { keyword: 'yi', mode: 'boundary' }, @@ -103,19 +96,16 @@ const BRAND_RULES: Record = { '豆包': [ { keyword: 'doubao', mode: 'includes' }, ], - // MiniMax: includesAny + hasExactSegment 'MiniMax': [ { keyword: 'minimax', mode: 'includes' }, { keyword: 'abab', mode: 'includes' }, { keyword: 'mini2.1', mode: 'segment' }, ], - // Cohere: includesAny + startsWithAny('embed-') 'Cohere': [ { keyword: 'command', mode: 'includes' }, { keyword: 'c4ai-', mode: 'includes' }, { keyword: 'embed-', mode: 'startsWith' }, ], - // Microsoft: includesAny + hasExactSegment('phi4') 'Microsoft': [ { keyword: 'microsoft/', mode: 'includes' }, { keyword: 'phi-', mode: 'includes' }, @@ -125,13 +115,11 @@ const BRAND_RULES: Record = { 'xAI': [ { keyword: 'grok', mode: 'includes' }, ], - // 阶跃星辰: includesAny('stepfun') + startsWithAny('step-', 'step3') '阶跃星辰': [ { keyword: 'stepfun', mode: 'includes' }, { keyword: 'step-', mode: 'startsWith' }, { keyword: 'step3', mode: 'startsWith' }, ], - // Stability: includesAny + startsWithAny('sd3') 'Stability': [ { keyword: 'flux', mode: 'includes' }, { keyword: 'stablediffusion', mode: 'includes' }, @@ -139,7 +127,6 @@ const BRAND_RULES: Record = { { keyword: 'sdxl', mode: 'includes' }, { keyword: 'sd3', mode: 'startsWith' }, ], - // NVIDIA: includesAny + startsWithAny('nv-') 'NVIDIA': [ { keyword: 'nvidia/', mode: 'includes' }, { keyword: 'nvclip', mode: 'includes' }, @@ -158,7 +145,6 @@ const BRAND_RULES: Record = { { keyword: 'baai/', mode: 'includes' }, { keyword: 'bge-', mode: 'includes' }, ], - // ByteDance: includesAny + startsWithAny('wan-', 'kat-') 'ByteDance': [ { keyword: 'bytedance', mode: 'includes' }, { keyword: 'seed-oss', mode: 'includes' }, @@ -171,12 +157,10 @@ const BRAND_RULES: Record = { 'InternLM': [ { keyword: 'internlm', mode: 'includes' }, ], - // Midjourney: includesAny + startsWithAny('mj_') 'Midjourney': [ { keyword: 'midjourney', mode: 'includes' }, { keyword: 'mj_', mode: 'startsWith' }, ], - // DeepL: startsWithAny('deepl-') + includesAny('deepl/') 'DeepL': [ { keyword: 'deepl/', mode: 'includes' }, { keyword: 'deepl-', mode: 'startsWith' }, @@ -186,16 +170,92 @@ const BRAND_RULES: Record = { ], }; +// ── Context building (ported from frontend BrandIcon.tsx) ── + +function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + /** - * Get all available brand names. + * Recursively split model name on `/`, `:`, `,` to build all candidate strings. + * Mirrors the frontend collectBrandCandidates exactly. */ +function collectCandidates(modelName: string): string[] { + const queue: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = value.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + queue.push(normalized); + }; + + push(modelName); + + for (let i = 0; i < queue.length; i++) { + const candidate = queue[i]!; + const cleaned = stripCommonWrappers(candidate); + push(cleaned); + if (cleaned.includes('/')) for (const part of cleaned.split('/')) push(part); + if (cleaned.includes(':')) for (const part of cleaned.split(':')) push(part); + if (cleaned.includes(',')) for (const part of cleaned.split(',')) push(part); + } + + return queue; +} + +type MatchContext = { + raw: string; // first candidate (normalized full name) + candidates: string[]; // all candidates from recursive splitting + segments: string[]; // candidates split on /,:,\s (NOT on - or _) +}; + +function buildMatchContext(modelName: string): MatchContext { + const candidates = collectCandidates(modelName); + const raw = candidates[0] || modelName.trim().toLowerCase(); + // Segments split on /,:,whitespace only — NOT on - or _ (matches frontend) + const segments = Array.from(new Set( + candidates + .flatMap((c) => c.split(/[/:,\s]+/g)) + .map((s) => s.trim()) + .filter(Boolean), + )); + return { raw, candidates, segments }; +} + +// ── Matching functions (mirror frontend includesAny / startsWithAny / hasExactSegment / matchesBoundary) ── + +function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean { + switch (rule.mode) { + case 'includes': + return ctx.raw.includes(rule.keyword) + || ctx.candidates.some((c) => c.includes(rule.keyword)); + case 'startsWith': + return ctx.raw.startsWith(rule.keyword) + || ctx.segments.some((s) => s.startsWith(rule.keyword)) + || ctx.candidates.some((c) => c.startsWith(rule.keyword)); + case 'segment': + return ctx.segments.includes(rule.keyword); + case 'boundary': { + const pattern = new RegExp(`(^|[^a-z0-9])${rule.keyword}(?=$|[^a-z0-9])`); + return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c)); + } + default: + return false; + } +} + +// ── Public API ── + export function getAllBrandNames(): string[] { return Object.keys(BRAND_RULES); } -/** - * Collect the rules for the blocked brands. - */ export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { const rules: KeywordRule[] = []; for (const brand of blockedBrands) { @@ -205,40 +265,9 @@ export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { return rules; } -/** - * Split model name into segments (matching the frontend's segment logic). - */ -function getSegments(lower: string): string[] { - return lower.split(/[/:,\s_\-]+/g).filter(Boolean); -} - -/** - * Check if a model name matches a single keyword rule. - */ -function matchesRule(lower: string, segments: string[], rule: KeywordRule): boolean { - switch (rule.mode) { - case 'includes': - return lower.includes(rule.keyword); - case 'startsWith': - // Match if any segment starts with the keyword, or the full name starts with it - return lower.startsWith(rule.keyword) || segments.some((seg) => seg.startsWith(rule.keyword)); - case 'segment': - return segments.includes(rule.keyword); - case 'boundary': { - const pattern = new RegExp(`(^|[^a-z0-9])${rule.keyword}(?=$|[^a-z0-9])`); - return pattern.test(lower); - } - default: - return false; - } -} - -/** - * Check if a model name matches any of the blocked brand rules. - */ export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean { - const lower = modelName.trim().toLowerCase(); - if (!lower || rules.length === 0) return false; - const segments = getSegments(lower); - return rules.some((rule) => matchesRule(lower, segments, rule)); + if (!modelName || rules.length === 0) return false; + const ctx = buildMatchContext(modelName); + if (!ctx.raw) return false; + return rules.some((rule) => matchesRule(ctx, rule)); } diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 4c24a99..68c7823 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -220,7 +220,6 @@ export default function Sites() { const highlightTimerRef = useRef(null); const toast = useToast(); const [disabledModels, setDisabledModels] = useState([]); - const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); const [availableModels, setAvailableModels] = useState([]); @@ -357,7 +356,6 @@ export default function Sites() { // Load disabled models and available models for this site setDisabledModelsLoading(true); setDisabledModels([]); - setDisabledModelInput(''); setAvailableModels([]); setDisabledModelSearch(''); Promise.all([ @@ -372,22 +370,6 @@ export default function Sites() { .finally(() => setDisabledModelsLoading(false)); }; - const handleAddDisabledModel = () => { - const model = disabledModelInput.trim(); - if (!model) return; - if (disabledModelSet.has(model)) { - toast.info(`模型 "${model}" 已在禁用列表中`); - setDisabledModelInput(''); - return; - } - setDisabledModels((prev) => [...prev, model]); - setDisabledModelInput(''); - }; - - const handleRemoveDisabledModel = (model: string) => { - setDisabledModels((prev) => prev.filter((m) => m !== model)); - }; - const handleSaveDisabledModels = async () => { if (!editor || editor.mode !== 'edit') return; setDisabledModelsSaving(true); @@ -1039,57 +1021,6 @@ export default function Sites() {
)} - {/* Manually disabled models not in available list */} - {(() => { - const availableSet = new Set(availableModels); - const manualOnly = disabledModels.filter((m) => !availableSet.has(m)); - if (manualOnly.length === 0) return null; - return ( -
-
手动添加的禁用模型(不在站点已发现列表中):
-
- {manualOnly.map((model) => ( - - {model} - - - ))} -
-
- ); - })()} - - {/* Manual input for models not in the list */} -
- setDisabledModelInput(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} - style={{ - flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none', - background: 'var(--color-bg)', color: 'var(--color-text-primary)', - }} - /> - -
@@ -1324,7 +1392,10 @@ export default function TokenRoutes() { endpointTypeList={endpointTypeList} groupRouteList={groupRouteList} collapsed={filterCollapsed} - onToggle={() => setFilterCollapsed((prev) => !prev)} + onToggle={() => { + if (filterCollapsed) loadCandidates(); + setFilterCollapsed((prev) => !prev); + }} /> )} @@ -1479,8 +1550,8 @@ export default function TokenRoutes() { onDeleteChannel={stableDeleteChannel} onToggleChannelEnabled={stableToggleChannelEnabled} onChannelDragEnd={stableChannelDragEnd} - missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} - missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} + missingTokenSiteItems={getMissingTokenSiteItems(route.id)} + missingTokenGroupItems={getMissingTokenGroupItems(route.id)} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} onSiteBlockModel={stableSiteBlockModel} @@ -1530,7 +1601,7 @@ export default function TokenRoutes() { routeTitle={resolveRouteTitle(addChannelModalRoute)} candidateView={getRouteCandidateView(addChannelModalRoute.id)} onSuccess={handleAddChannelSuccess} - missingTokenHints={routeMissingTokenIndex[addChannelModalRoute.id] || []} + missingTokenHints={getRouteMissingTokenHints(addChannelModalRoute.id)} onCreateTokenForMissing={handleCreateTokenForMissingAccount} existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))} /> From 3195282c4e821c1ceb351c1ad7c606b44e6e11cc Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 18:04:46 +0800 Subject: [PATCH 07/14] fix: address review issues in TokenRoutes performance optimization - Escape modelName/siteName in innerHTML to prevent XSS - Use content-based routePatternsKey for cache invalidation instead of length-only keys that miss content changes - Extract shared baseFilteredRoutes to eliminate duplicated filter logic between enabledCounts and filteredRoutes - Add candidatesPromiseRef to deduplicate concurrent loadCandidates calls --- src/web/pages/TokenRoutes.tsx | 142 ++++++++++++++-------------------- 1 file changed, 57 insertions(+), 85 deletions(-) diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index b83ad75..9cfad2b 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -107,6 +107,10 @@ function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): str return '已切换为权重随机策略'; } +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + export default function TokenRoutes() { const navigate = useNavigate(); const [routeSummaries, setRouteSummaries] = useState([]); @@ -158,6 +162,7 @@ export default function TokenRoutes() { const toast = useToast(); const candidatesLoadedRef = useRef(false); + const candidatesPromiseRef = useRef | null>(null); const loadRouteDecisions = async ( routeRows: RouteSummaryRow[], @@ -226,24 +231,32 @@ export default function TokenRoutes() { } }; - const loadCandidates = async (force?: boolean) => { + const loadCandidates = (force?: boolean) => { if (candidatesLoadedRef.current && !force) return; + if (candidatesPromiseRef.current && !force) return; candidatesLoadedRef.current = true; - try { - const candidateRows = await api.getModelTokenCandidates(); - startTransition(() => { - setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); - setMissingTokenModelsByName( - normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), - ); - setMissingTokenGroupModelsByName( - normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName), - ); - setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); - }); - } catch { - candidatesLoadedRef.current = false; - } + const promise = (async () => { + try { + const candidateRows = await api.getModelTokenCandidates(); + startTransition(() => { + setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); + setMissingTokenModelsByName( + normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), + ); + setMissingTokenGroupModelsByName( + normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName), + ); + setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); + }); + } catch { + candidatesLoadedRef.current = false; + } finally { + if (candidatesPromiseRef.current === promise) { + candidatesPromiseRef.current = null; + } + } + })(); + candidatesPromiseRef.current = promise; }; const load = async () => { @@ -494,10 +507,11 @@ export default function TokenRoutes() { }; // Stable derived value: only changes when route patterns change (not on enabled toggle) + const routePatternsKey = visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(','); const routePatterns = useMemo( () => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })), // eslint-disable-next-line react-hooks/exhaustive-deps - [visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')], + [routePatternsKey], ); const routeBrandById = useMemo(() => { @@ -677,56 +691,10 @@ export default function TokenRoutes() { }) ), [listVisibleRoutes, sortBy, sortDir]); - const enabledCounts = useMemo(() => { - // Count enabled/disabled within the currently filtered scope (excluding the enabled filter itself) - let base = sortedRoutes; - - if (activeGroupFilter === '__all__') { - base = base.filter((route) => !isRouteExactModel(route)); - } else if (typeof activeGroupFilter === 'number') { - base = base.filter((route) => route.id === activeGroupFilter); - } - if (activeBrand) { - if (activeBrand === '__other__') { - base = base.filter((route) => !(routeBrandById.get(route.id) || null)); - } else { - base = base.filter((route) => (routeBrandById.get(route.id)?.name || '') === activeBrand); - } - } - if (activeSite) { - base = base.filter((route) => route.siteNames?.includes(activeSite)); - } - if (activeEndpointType) { - base = base.filter((route) => (routeEndpointTypesByRouteId[route.id] || new Set()).has(activeEndpointType)); - } - if (search.trim()) { - const q = search.trim().toLowerCase(); - base = base.filter((route) => { - const modelPattern = route.modelPattern.toLowerCase(); - const displayName = (route.displayName || '').toLowerCase(); - const title = resolveRouteTitle(route).toLowerCase(); - return modelPattern.includes(q) || displayName.includes(q) || title.includes(q); - }); - } - - let enabled = 0; - let disabled = 0; - for (const route of base) { - if (route.enabled) enabled++; - else disabled++; - } - return { enabled, disabled }; - }, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]); - - const filteredRoutes = useMemo(() => { + // Shared base filter: all filters EXCEPT enabledFilter + const baseFilteredRoutes = useMemo(() => { let list = sortedRoutes; - if (enabledFilter === 'enabled') { - list = list.filter((route) => route.enabled); - } else if (enabledFilter === 'disabled') { - list = list.filter((route) => !route.enabled); - } - if (activeGroupFilter === '__all__') { list = list.filter((route) => !isRouteExactModel(route)); } else if (typeof activeGroupFilter === 'number') { @@ -742,9 +710,7 @@ export default function TokenRoutes() { } if (activeSite) { - list = list.filter((route) => - route.siteNames?.includes(activeSite), - ); + list = list.filter((route) => route.siteNames?.includes(activeSite)); } if (activeEndpointType) { @@ -764,17 +730,23 @@ export default function TokenRoutes() { } return list; - }, [ - sortedRoutes, - enabledFilter, - activeGroupFilter, - activeBrand, - activeSite, - activeEndpointType, - search, - routeBrandById, - routeEndpointTypesByRouteId, - ]); + }, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]); + + const enabledCounts = useMemo(() => { + let enabled = 0; + let disabled = 0; + for (const route of baseFilteredRoutes) { + if (route.enabled) enabled++; + else disabled++; + } + return { enabled, disabled }; + }, [baseFilteredRoutes]); + + const filteredRoutes = useMemo(() => { + if (enabledFilter === 'all') return baseFilteredRoutes; + if (enabledFilter === 'enabled') return baseFilteredRoutes.filter((route) => route.enabled); + return baseFilteredRoutes.filter((route) => !route.enabled); + }, [baseFilteredRoutes, enabledFilter]); useEffect(() => { setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); @@ -806,7 +778,7 @@ export default function TokenRoutes() { // Lazy per-route candidate index — only computes for routes actually accessed const candidateIndexCacheRef = useRef<{ key: string; cache: Map }>({ key: '', cache: new Map() }); - const candidateIndexCacheKey = `${routePatterns.length}:${Object.keys(modelCandidates).length}`; + const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}`; if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) { candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() }; } @@ -825,7 +797,7 @@ export default function TokenRoutes() { // Lazy per-route missing token index const missingTokenCacheRef = useRef<{ key: string; cache: Map }>({ key: '', cache: new Map() }); - const missingTokenCacheKey = `${routePatterns.length}:${Object.keys(missingTokenModelsByName).length}`; + const missingTokenCacheKey = `${routePatternsKey}|${Object.keys(missingTokenModelsByName).length}`; if (missingTokenCacheRef.current.key !== missingTokenCacheKey) { missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() }; } @@ -844,7 +816,7 @@ export default function TokenRoutes() { // Lazy per-route missing token group index const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map }>({ key: '', cache: new Map() }); - const missingTokenGroupCacheKey = `${routePatterns.length}:${Object.keys(missingTokenGroupModelsByName).length}`; + const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}`; if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) { missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() }; } @@ -1026,7 +998,7 @@ export default function TokenRoutes() { dialog.innerHTML = `
确认站点屏蔽
- 将模型「${modelName}」加入站点「${siteName}」的禁用列表。
执行后将自动触发路由重建,该站点下此模型的通道将不再生成。 + 将模型「${escapeHtml(modelName)}」加入站点「${escapeHtml(siteName)}」的禁用列表。
执行后将自动触发路由重建,该站点下此模型的通道将不再生成。
@@ -1513,8 +1485,8 @@ export default function TokenRoutes() { onSaveToken={stableChannelTokenSave} onDeleteChannel={stableDeleteChannel} onChannelDragEnd={stableChannelDragEnd} - missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} - missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} + missingTokenSiteItems={getMissingTokenSiteItems(route.id)} + missingTokenGroupItems={getMissingTokenGroupItems(route.id)} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} expandedSourceGroupMap={expandedSourceGroupMap} From 2f8dae70c2ca8b097d38b41d910ee1f42271f3d0 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 19:18:44 +0800 Subject: [PATCH 08/14] fix: add missing onToggleChannelEnabled and onSiteBlockModel props to mobile RouteCard --- src/web/pages/TokenRoutes.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 9cfad2b..4e6b716 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -1484,11 +1484,13 @@ export default function TokenRoutes() { onTokenDraftChange={stableTokenDraftChange} onSaveToken={stableChannelTokenSave} onDeleteChannel={stableDeleteChannel} + onToggleChannelEnabled={stableToggleChannelEnabled} onChannelDragEnd={stableChannelDragEnd} missingTokenSiteItems={getMissingTokenSiteItems(route.id)} missingTokenGroupItems={getMissingTokenGroupItems(route.id)} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} + onSiteBlockModel={stableSiteBlockModel} expandedSourceGroupMap={expandedSourceGroupMap} onToggleSourceGroup={stableToggleSourceGroup} /> From 911d76fa08ae209a209feb23a673d6e5cf8caa49 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 19:23:54 +0800 Subject: [PATCH 09/14] fix: add channel toggle and site-block buttons to mobile channel row --- .../pages/token-routes/SortableChannelRow.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/web/pages/token-routes/SortableChannelRow.tsx b/src/web/pages/token-routes/SortableChannelRow.tsx index d8886e1..668cc68 100644 --- a/src/web/pages/token-routes/SortableChannelRow.tsx +++ b/src/web/pages/token-routes/SortableChannelRow.tsx @@ -266,6 +266,22 @@ export function SortableChannelRow({ {isUpdatingToken ? : '保存'} + + + {onSiteBlockModel && channel.site?.id ? ( + + ) : null} + + + + {onSiteBlockModel && channel.site?.id ? ( + + ) : null} + setShowFilters(false)} title={tr('筛选路由')}> ) : ( Date: Sun, 22 Mar 2026 20:26:45 +0800 Subject: [PATCH 11/14] fix: use ref-based stale guard for site disabled-models fetch Replace closure-captured editor check with loadingModelsSiteIdRef so the stale-response guard works correctly across rapid site switches. Also gate setDisabledModelsLoading(false) behind the same ref check. --- src/web/pages/Sites.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 0d9848e..75819fa 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -216,6 +216,7 @@ export default function Sites() { count?: number; }>(null); const lastEditorRef = useRef(null); + const loadingModelsSiteIdRef = useRef(null); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef(null); const toast = useToast(); @@ -356,6 +357,7 @@ export default function Sites() { scrollToEditorTop(); // Load disabled models and available models for this site const loadSiteId = site.id; + loadingModelsSiteIdRef.current = loadSiteId; setDisabledModelsLoading(true); setDisabledModels([]); setAvailableModels([]); @@ -366,7 +368,7 @@ export default function Sites() { ]) .then(([disabledRes, availableRes]: any[]) => { // Guard: only apply if we're still editing the same site - if (editor?.mode !== 'edit' && loadSiteId !== site.id) return; + if (loadingModelsSiteIdRef.current !== loadSiteId) return; setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []); setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []); }) @@ -375,7 +377,9 @@ export default function Sites() { // Preserve previous (empty) model lists — don't clear UI silently }) .finally(() => { - setDisabledModelsLoading(false); + if (loadingModelsSiteIdRef.current === loadSiteId) { + setDisabledModelsLoading(false); + } }); }; From d278b41be2c9868f951714bc36574e3bbff5ee81 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 20:44:55 +0800 Subject: [PATCH 12/14] fix: address review issues in brand filter sync, model guard, route counts, and candidate loading - Sync blockedBrands local state after save to prevent stale chips - Defer getBrandList from blocking Settings bootstrap - Guard model management UI on brandGroups instead of availableModels - Deduplicate single-model add in disabled models - Skip read-only/zero-channel routes in enabledCounts and filter - Add sequence counter to loadCandidates to prevent force race condition - Use stableAddChannel for mobile add-channel button --- src/web/pages/Settings.tsx | 19 +++++++------------ src/web/pages/Sites.tsx | 4 ++-- src/web/pages/TokenRoutes.tsx | 14 ++++++++++---- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 5a2f953..cf89640 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -424,14 +424,6 @@ export default function Settings() { api.getRoutesLite(), api.getRuntimeDatabaseConfig(), ]); - let brandListBrands: string[] = []; - try { - const brandListRes = await api.getBrandList(); - brandListBrands = Array.isArray(brandListRes?.brands) ? brandListRes.brands : []; - } catch { - // best-effort: brand list is non-critical - } - setAllBrandNames(brandListBrands); setMaskedToken(authInfo.masked || '****'); setRuntime({ checkinCron: runtimeInfo.checkinCron || '0 8 * * *', @@ -508,6 +500,10 @@ export default function Settings() { } finally { setLoading(false); } + // Load brand list in background (non-blocking, best-effort) + api.getBrandList() + .then((res: any) => setAllBrandNames(Array.isArray(res?.brands) ? res.brands : [])) + .catch(() => {/* best-effort */}); }; useEffect(() => { @@ -834,10 +830,9 @@ export default function Settings() { setSavingBrandFilter(true); try { const res = await api.updateRuntimeSettings({ globalBlockedBrands: blockedBrands }); - setRuntime((prev) => ({ - ...prev, - globalBlockedBrands: Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands, - })); + const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands; + setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved })); + setBlockedBrands(resolved); toast.success('品牌屏蔽设置已保存'); try { await api.rebuildRoutes(false); diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 75819fa..debd182 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -953,7 +953,7 @@ export default function Sites() { ) : ( <> {/* Search and brand group controls */} - {availableModels.length > 0 && ( + {brandGroups.length > 0 && (
{ if (e.target.checked) { - setDisabledModels((prev) => [...prev, model]); + setDisabledModels((prev) => Array.from(new Set([...prev, model]))); } else { setDisabledModels((prev) => prev.filter((m) => m !== model)); } diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 3c0eeef..fa02d4e 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -164,6 +164,7 @@ export default function TokenRoutes() { const candidatesLoadedRef = useRef(false); const candidatesPromiseRef = useRef | null>(null); const candidatesVersionRef = useRef(0); + const candidatesSeqRef = useRef(0); const loadRouteDecisions = async ( routeRows: RouteSummaryRow[], @@ -235,10 +236,12 @@ export default function TokenRoutes() { const loadCandidates = (force?: boolean) => { if (candidatesLoadedRef.current && !force) return; if (candidatesPromiseRef.current && !force) return; + const seq = ++candidatesSeqRef.current; candidatesLoadedRef.current = true; const promise = (async () => { try { const candidateRows = await api.getModelTokenCandidates(); + if (candidatesSeqRef.current !== seq) return; // stale startTransition(() => { setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName); setMissingTokenModelsByName( @@ -251,7 +254,7 @@ export default function TokenRoutes() { }); candidatesVersionRef.current = Date.now(); } catch { - candidatesLoadedRef.current = false; + if (candidatesSeqRef.current === seq) candidatesLoadedRef.current = false; } finally { if (candidatesPromiseRef.current === promise) { candidatesPromiseRef.current = null; @@ -740,6 +743,7 @@ export default function TokenRoutes() { let enabled = 0; let disabled = 0; for (const route of baseFilteredRoutes) { + if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) continue; if (route.enabled) enabled++; else disabled++; } @@ -748,8 +752,10 @@ export default function TokenRoutes() { const filteredRoutes = useMemo(() => { if (enabledFilter === 'all') return baseFilteredRoutes; - if (enabledFilter === 'enabled') return baseFilteredRoutes.filter((route) => route.enabled); - return baseFilteredRoutes.filter((route) => !route.enabled); + return baseFilteredRoutes.filter((route) => { + if (route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true) return false; + return enabledFilter === 'enabled' ? route.enabled : !route.enabled; + }); }, [baseFilteredRoutes, enabledFilter]); useEffect(() => { @@ -1451,7 +1457,7 @@ export default function TokenRoutes() { From 9a5e2bbc606a34bc0ecabd6e94bf5c5b4b5f982b Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sun, 22 Mar 2026 23:13:51 +0800 Subject: [PATCH 13/14] fix: prevent brand list loading indicator from hanging on API failure --- src/web/pages/Settings.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index cf89640..4f1ece5 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -235,7 +235,7 @@ export default function Settings() { const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); - const [allBrandNames, setAllBrandNames] = useState([]); + const [allBrandNames, setAllBrandNames] = useState(null); const [blockedBrands, setBlockedBrands] = useState([]); const [savingBrandFilter, setSavingBrandFilter] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false); @@ -503,7 +503,7 @@ export default function Settings() { // Load brand list in background (non-blocking, best-effort) api.getBrandList() .then((res: any) => setAllBrandNames(Array.isArray(res?.brands) ? res.brands : [])) - .catch(() => {/* best-effort */}); + .catch(() => setAllBrandNames([])); }; useEffect(() => { @@ -1464,7 +1464,7 @@ export default function Settings() { 屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
- {allBrandNames.map((brand) => { + {(allBrandNames || []).map((brand) => { const isBlocked = blockedBrands.includes(brand); return (
{blockedBrands.length > 0 && (
From 90e9140c7046b1cf6fc77abbdaf30a5397be17a9 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 23:58:23 +0800 Subject: [PATCH 14/14] fix: preserve site disabled models on partial load failure --- src/web/pages/Sites.tsx | 83 +++++++++--- .../pages/sites.disabled-models-save.test.tsx | 123 +++++++++++++++--- 2 files changed, 170 insertions(+), 36 deletions(-) diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index debd182..234c873 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -221,6 +221,7 @@ export default function Sites() { const highlightTimerRef = useRef(null); const toast = useToast(); const [disabledModels, setDisabledModels] = useState([]); + const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); const [availableModels, setAvailableModels] = useState([]); @@ -355,32 +356,54 @@ export default function Sites() { setEditor({ mode: 'edit', editingSiteId: site.id }); setForm(siteFormFromSite(site)); scrollToEditorTop(); - // Load disabled models and available models for this site + // Load disabled models and discovered models independently so a best-effort + // availability fetch cannot wipe the existing disabled-model state. const loadSiteId = site.id; loadingModelsSiteIdRef.current = loadSiteId; setDisabledModelsLoading(true); setDisabledModels([]); + setDisabledModelInput(''); setAvailableModels([]); setDisabledModelSearch(''); - Promise.all([ - api.getSiteDisabledModels(site.id), - api.getSiteAvailableModels(site.id), - ]) - .then(([disabledRes, availableRes]: any[]) => { - // Guard: only apply if we're still editing the same site + let pendingLoads = 2; + const markLoadFinished = () => { + pendingLoads -= 1; + if (pendingLoads <= 0 && loadingModelsSiteIdRef.current === loadSiteId) { + setDisabledModelsLoading(false); + } + }; + + api.getSiteDisabledModels(site.id) + .then((disabledRes: any) => { if (loadingModelsSiteIdRef.current !== loadSiteId) return; setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []); + }) + .catch((err: any) => { + console.warn('Failed to load site disabled models:', err?.message || err); + }) + .finally(markLoadFinished); + + api.getSiteAvailableModels(site.id) + .then((availableRes: any) => { + if (loadingModelsSiteIdRef.current !== loadSiteId) return; setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []); }) .catch((err: any) => { - console.warn('Failed to load site models:', err?.message || err); - // Preserve previous (empty) model lists — don't clear UI silently + console.warn('Failed to load site available models:', err?.message || err); }) - .finally(() => { - if (loadingModelsSiteIdRef.current === loadSiteId) { - setDisabledModelsLoading(false); - } - }); + .finally(markLoadFinished); + }; + + const handleAddDisabledModel = () => { + const model = disabledModelInput.trim(); + if (!model) return; + if (disabledModels.includes(model)) { + toast.info(`模型 "${model}" 已在禁用列表中`); + setDisabledModelInput(''); + return; + } + setDisabledModels((prev) => [...prev, model]); + setDisabledModelInput(''); }; const handleSaveDisabledModels = async () => { @@ -953,7 +976,7 @@ export default function Sites() { ) : ( <> {/* Search and brand group controls */} - {brandGroups.length > 0 && ( + {brandGroups.length > 0 ? (
+ ) : ( +
+ 暂无已发现模型,仍可手动添加需要屏蔽的模型名。 +
)} +
+ setDisabledModelInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddDisabledModel(); + } + }} + style={{ + flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none', + background: 'var(--color-bg)', color: 'var(--color-text-primary)', + }} + /> + +
+