forked from boos/BoosAPI
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
This commit is contained in:
@@ -1,69 +1,244 @@
|
|||||||
/**
|
/**
|
||||||
* Server-side brand matching for global brand filtering.
|
* Server-side brand matching for global brand filtering.
|
||||||
*
|
*
|
||||||
* Brand keywords are extracted from the frontend BrandIcon.tsx BRAND_DEFINITIONS.
|
* Matching semantics are aligned with the frontend BrandIcon.tsx BRAND_DEFINITIONS:
|
||||||
* Each brand maps to a set of keywords that, when found in a model name, identify
|
* - `includes`: substring match (e.g. 'claude' matches 'claude-3-opus')
|
||||||
* the model as belonging to that brand.
|
* - `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<string, string[]> = {
|
type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary';
|
||||||
'OpenAI': ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini', 'o1', 'o3', 'o4', 'tts'],
|
type KeywordRule = { keyword: string; mode: MatchMode };
|
||||||
'Anthropic': ['claude'],
|
|
||||||
'Google': ['gemini', 'gemma', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa', 'veo'],
|
const BRAND_RULES: Record<string, KeywordRule[]> = {
|
||||||
'DeepSeek': ['deepseek', 'ds-chat'],
|
// OpenAI: all startsWithAny in frontend
|
||||||
'通义千问': ['qwen', 'qwq', 'tongyi'],
|
'OpenAI': [
|
||||||
'智谱 AI': ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo'],
|
{ keyword: 'gpt', mode: 'startsWith' },
|
||||||
'Meta': ['llama', 'code-llama', 'codellama'],
|
{ keyword: 'chatgpt', mode: 'startsWith' },
|
||||||
'Mistral': ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral'],
|
{ keyword: 'dall-e', mode: 'startsWith' },
|
||||||
'Moonshot': ['moonshot', 'kimi'],
|
{ keyword: 'whisper', mode: 'startsWith' },
|
||||||
'零一万物': ['yi-'],
|
{ keyword: 'text-embedding', mode: 'startsWith' },
|
||||||
'文心一言': ['ernie', 'eb-'],
|
{ keyword: 'text-moderation', mode: 'startsWith' },
|
||||||
'讯飞星火': ['spark', 'generalv'],
|
{ keyword: 'davinci', mode: 'startsWith' },
|
||||||
'腾讯混元': ['hunyuan', 'tencent-hunyuan'],
|
{ keyword: 'babbage', mode: 'startsWith' },
|
||||||
'豆包': ['doubao'],
|
{ keyword: 'codex-mini', mode: 'startsWith' },
|
||||||
'MiniMax': ['minimax', 'abab'],
|
{ keyword: 'o1', mode: 'startsWith' },
|
||||||
'Cohere': ['command', 'c4ai-'],
|
{ keyword: 'o3', mode: 'startsWith' },
|
||||||
'Microsoft': ['phi-', 'kosmos'],
|
{ keyword: 'o4', mode: 'startsWith' },
|
||||||
'xAI': ['grok'],
|
{ keyword: 'tts', mode: 'startsWith' },
|
||||||
'阶跃星辰': ['stepfun', 'step-'],
|
],
|
||||||
'Stability': ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl', 'sd3'],
|
// Anthropic: includesAny
|
||||||
'NVIDIA': ['nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'],
|
'Anthropic': [
|
||||||
'IBM': ['granite'],
|
{ keyword: 'claude', mode: 'includes' },
|
||||||
'BAAI': ['bge-'],
|
],
|
||||||
'ByteDance': ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot', 'wan-', 'kat-'],
|
// Google: includesAny + startsWithAny for veo/google/
|
||||||
'InternLM': ['internlm'],
|
'Google': [
|
||||||
'Midjourney': ['midjourney', 'mj_'],
|
{ keyword: 'gemini', mode: 'includes' },
|
||||||
'DeepL': ['deepl-', 'deepl/'],
|
{ keyword: 'gemma', mode: 'includes' },
|
||||||
'Jina AI': ['jina'],
|
{ 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.
|
* Get all available brand names.
|
||||||
*/
|
*/
|
||||||
export function getAllBrandNames(): string[] {
|
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[] {
|
export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
|
||||||
const keywords: string[] = [];
|
const rules: KeywordRule[] = [];
|
||||||
for (const brand of blockedBrands) {
|
for (const brand of blockedBrands) {
|
||||||
const brandKeywords = BRAND_KEYWORDS[brand];
|
const brandRules = BRAND_RULES[brand];
|
||||||
if (brandKeywords) {
|
if (brandRules) rules.push(...brandRules);
|
||||||
keywords.push(...brandKeywords);
|
|
||||||
}
|
}
|
||||||
}
|
return rules;
|
||||||
return keywords;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model name matches any of the blocked keywords.
|
* Split model name into segments (matching the frontend's segment logic).
|
||||||
* Uses a case-insensitive substring match (same approach as frontend BrandIcon).
|
|
||||||
*/
|
*/
|
||||||
export function isModelBlockedByBrand(modelName: string, blockedKeywords: string[]): boolean {
|
function getSegments(lower: string): string[] {
|
||||||
const lower = modelName.trim().toLowerCase();
|
return lower.split(/[/:,\s_\-]+/g).filter(Boolean);
|
||||||
if (!lower) return false;
|
}
|
||||||
return blockedKeywords.some((keyword) => lower.includes(keyword));
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
supportsDirectAccountRoutingConnection,
|
supportsDirectAccountRoutingConnection,
|
||||||
} from './accountExtraConfig.js';
|
} from './accountExtraConfig.js';
|
||||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||||
import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js';
|
import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||||
@@ -1142,14 +1142,14 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load global brand filter
|
// Load global brand filter
|
||||||
const blockedBrandKeywords = getBlockedModelKeywords(config.globalBlockedBrands);
|
const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands);
|
||||||
|
|
||||||
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
|
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 addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
|
||||||
const modelName = (modelNameRaw || '').trim();
|
const modelName = (modelNameRaw || '').trim();
|
||||||
if (!modelName) return;
|
if (!modelName) return;
|
||||||
if (isModelDisabledForSite(siteId, 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());
|
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||||||
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
||||||
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
||||||
|
|||||||
+44
-48
@@ -226,6 +226,31 @@ export default function Sites() {
|
|||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
const [disabledModelSearch, setDisabledModelSearch] = useState('');
|
const [disabledModelSearch, setDisabledModelSearch] = useState('');
|
||||||
|
|
||||||
|
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||||
|
|
||||||
|
const brandGroups = useMemo(() => {
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
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;
|
if (editor) lastEditorRef.current = editor;
|
||||||
const activeEditor = editor || lastEditorRef.current;
|
const activeEditor = editor || lastEditorRef.current;
|
||||||
const isEditing = activeEditor?.mode === 'edit';
|
const isEditing = activeEditor?.mode === 'edit';
|
||||||
@@ -350,7 +375,7 @@ export default function Sites() {
|
|||||||
const handleAddDisabledModel = () => {
|
const handleAddDisabledModel = () => {
|
||||||
const model = disabledModelInput.trim();
|
const model = disabledModelInput.trim();
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
if (disabledModels.includes(model)) {
|
if (disabledModelSet.has(model)) {
|
||||||
toast.info(`模型 "${model}" 已在禁用列表中`);
|
toast.info(`模型 "${model}" 已在禁用列表中`);
|
||||||
setDisabledModelInput('');
|
setDisabledModelInput('');
|
||||||
return;
|
return;
|
||||||
@@ -946,31 +971,18 @@ export default function Sites() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Brand group quick actions */}
|
{/* Brand group quick actions */}
|
||||||
{(() => {
|
|
||||||
const brandGroups = new Map<string, string[]>();
|
|
||||||
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 (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: '24px' }}>按品牌全选:</span>
|
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: '24px' }}>按品牌全选:</span>
|
||||||
{sortedBrands.map(([brandName, models]) => {
|
{brandGroups.map(([brandName, models]) => {
|
||||||
const allDisabled = models.every((m) => disabledModels.includes(m));
|
const allDisabled = models.every((m) => disabledModelSet.has(m));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={brandName}
|
key={brandName}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allDisabled) {
|
if (allDisabled) {
|
||||||
setDisabledModels((prev) => prev.filter((m) => !models.includes(m)));
|
const removeSet = new Set(models);
|
||||||
|
setDisabledModels((prev) => prev.filter((m) => !removeSet.has(m)));
|
||||||
} else {
|
} else {
|
||||||
setDisabledModels((prev) => Array.from(new Set([...prev, ...models])));
|
setDisabledModels((prev) => Array.from(new Set([...prev, ...models])));
|
||||||
}
|
}
|
||||||
@@ -984,46 +996,29 @@ export default function Sites() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{/* Checkbox list */}
|
{/* Checkbox list */}
|
||||||
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '4px 0' }}>
|
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '4px 0' }}>
|
||||||
{(() => {
|
{filteredBrandGroups.length === 0 ? (
|
||||||
const q = disabledModelSearch.trim().toLowerCase();
|
<div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--color-text-muted)' }}>无匹配模型</div>
|
||||||
const filtered = q ? availableModels.filter((m) => m.toLowerCase().includes(q)) : availableModels;
|
) : filteredBrandGroups.map(([brandName, models]) => (
|
||||||
if (filtered.length === 0) {
|
|
||||||
return <div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--color-text-muted)' }}>无匹配模型</div>;
|
|
||||||
}
|
|
||||||
// Group by brand
|
|
||||||
const brandGroups = new Map<string, string[]>();
|
|
||||||
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]) => (
|
|
||||||
<div key={brandName}>
|
<div key={brandName}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
{brandName} ({models.length})
|
{brandName} ({models.length})
|
||||||
</div>
|
</div>
|
||||||
{models.map((model) => (
|
{models.map((model) => {
|
||||||
|
const isDisabled = disabledModelSet.has(model);
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={model}
|
key={model}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8, padding: '3px 12px',
|
display: 'flex', alignItems: 'center', gap: 8, padding: '3px 12px',
|
||||||
fontSize: 12, cursor: 'pointer', lineHeight: 1.6,
|
fontSize: 12, cursor: 'pointer', lineHeight: 1.6,
|
||||||
background: disabledModels.includes(model) ? 'color-mix(in srgb, var(--color-warning) 8%, transparent)' : 'transparent',
|
background: isDisabled ? 'color-mix(in srgb, var(--color-warning) 8%, transparent)' : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={disabledModels.includes(model)}
|
checked={isDisabled}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
setDisabledModels((prev) => [...prev, model]);
|
setDisabledModels((prev) => [...prev, model]);
|
||||||
@@ -1032,21 +1027,22 @@ export default function Sites() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: disabledModels.includes(model) ? 'var(--color-warning)' : 'var(--color-text-primary)' }}>
|
<span style={{ color: isDisabled ? 'var(--color-warning)' : 'var(--color-text-primary)' }}>
|
||||||
{model}
|
{model}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
));
|
))}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Manually disabled models not in available list */}
|
{/* 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;
|
if (manualOnly.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 10 }}>
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
|||||||
@@ -641,16 +641,6 @@ export default function TokenRoutes() {
|
|||||||
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
|
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
|
||||||
}, [activeGroupFilter, listVisibleRoutes]);
|
}, [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(() => (
|
const sortedRoutes = useMemo(() => (
|
||||||
[...listVisibleRoutes].sort((a, b) => {
|
[...listVisibleRoutes].sort((a, b) => {
|
||||||
if (sortBy === 'channelCount') {
|
if (sortBy === 'channelCount') {
|
||||||
@@ -663,6 +653,47 @@ export default function TokenRoutes() {
|
|||||||
})
|
})
|
||||||
), [listVisibleRoutes, sortBy, sortDir]);
|
), [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<string>()).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(() => {
|
const filteredRoutes = useMemo(() => {
|
||||||
let list = sortedRoutes;
|
let list = sortedRoutes;
|
||||||
|
|
||||||
@@ -918,15 +949,33 @@ export default function TokenRoutes() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = routeSummaries.find((r) => r.id === routeId);
|
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) {
|
if (!modelName) {
|
||||||
toast.error('找不到要屏蔽的模型名');
|
toast.error('该通道没有精确模型名,无法使用站点屏蔽(通配符路由请在站点编辑中手动禁用)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const siteName = channel.site.name || '未知站点';
|
const siteName = channel.site.name || '未知站点';
|
||||||
const confirmed = window.confirm(
|
const confirmed = await new Promise<boolean>((resolve) => {
|
||||||
`确定将模型「${modelName}」加入站点「${siteName}」的禁用列表吗?\n\n执行后将自动触发路由重建,该站点下此模型的通道将不再生成。`,
|
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 = `
|
||||||
|
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认站点屏蔽</div>
|
||||||
|
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
|
||||||
|
将模型「<b>${modelName}</b>」加入站点「<b>${siteName}</b>」的禁用列表。<br/>执行后将自动触发路由重建,该站点下此模型的通道将不再生成。
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:8px">
|
||||||
|
<button id="__sb_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
|
||||||
|
<button id="__sb_confirm" class="btn btn-warning" style="padding:6px 16px">确认屏蔽</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user