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:
@@ -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),
|
||||
|
||||
@@ -146,6 +146,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
|
||||
}
|
||||
|
||||
const globalBlockedBrands = parseSettingFromMap<string[]>(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<unknown>(settingsMap, 'codex_header_defaults');
|
||||
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
|
||||
const next = codexHeaderDefaults as Record<string, unknown>;
|
||||
|
||||
@@ -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<RoutingWeights>;
|
||||
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 地址');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user