feat: add model filter system with site-level batch disable, global brand filter, route status filter, and channel site-block shortcut

- 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
This commit is contained in:
Hureru
2026-03-21 17:55:25 +08:00
parent 3cc6b7be45
commit cf6ed7db91
20 changed files with 621 additions and 49 deletions
+69
View File
@@ -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<string, string[]> = {
'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));
}
+6
View File
@@ -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<string, Map<string, { accountId: number; tokenId: number | null }>>();
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 });