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
This commit is contained in:
@@ -1,18 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Server-side brand matching for global brand filtering.
|
* Server-side brand matching for global brand filtering.
|
||||||
*
|
*
|
||||||
* Matching semantics are aligned with the frontend BrandIcon.tsx BRAND_DEFINITIONS:
|
* Context-building logic is ported from the frontend BrandIcon.tsx
|
||||||
* - `includes`: substring match (e.g. 'claude' matches 'claude-3-opus')
|
* (collectBrandCandidates + buildMatchContext) to ensure identical
|
||||||
* - `startsWith`: segment-level startsWith (e.g. 'gpt' matches 'gpt-4o' but not 'some-gpt')
|
* matching behavior between route display and rebuild filtering.
|
||||||
* - `segment`: exact segment match after splitting on /:-_ delimiters
|
|
||||||
* - `boundary`: word-boundary regex match
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary';
|
type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary';
|
||||||
type KeywordRule = { keyword: string; mode: MatchMode };
|
type KeywordRule = { keyword: string; mode: MatchMode };
|
||||||
|
|
||||||
const BRAND_RULES: Record<string, KeywordRule[]> = {
|
const BRAND_RULES: Record<string, KeywordRule[]> = {
|
||||||
// OpenAI: all startsWithAny in frontend
|
|
||||||
'OpenAI': [
|
'OpenAI': [
|
||||||
{ keyword: 'gpt', mode: 'startsWith' },
|
{ keyword: 'gpt', mode: 'startsWith' },
|
||||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
{ keyword: 'chatgpt', mode: 'startsWith' },
|
||||||
@@ -28,11 +25,9 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
{ keyword: 'o4', mode: 'startsWith' },
|
{ keyword: 'o4', mode: 'startsWith' },
|
||||||
{ keyword: 'tts', mode: 'startsWith' },
|
{ keyword: 'tts', mode: 'startsWith' },
|
||||||
],
|
],
|
||||||
// Anthropic: includesAny
|
|
||||||
'Anthropic': [
|
'Anthropic': [
|
||||||
{ keyword: 'claude', mode: 'includes' },
|
{ keyword: 'claude', mode: 'includes' },
|
||||||
],
|
],
|
||||||
// Google: includesAny + startsWithAny for veo/google/
|
|
||||||
'Google': [
|
'Google': [
|
||||||
{ keyword: 'gemini', mode: 'includes' },
|
{ keyword: 'gemini', mode: 'includes' },
|
||||||
{ keyword: 'gemma', mode: 'includes' },
|
{ keyword: 'gemma', mode: 'includes' },
|
||||||
@@ -48,7 +43,6 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
{ keyword: 'aqa', mode: 'includes' },
|
{ keyword: 'aqa', mode: 'includes' },
|
||||||
{ keyword: 'veo', mode: 'startsWith' },
|
{ keyword: 'veo', mode: 'startsWith' },
|
||||||
],
|
],
|
||||||
// DeepSeek: includesAny + hasExactSegment
|
|
||||||
'DeepSeek': [
|
'DeepSeek': [
|
||||||
{ keyword: 'deepseek', mode: 'includes' },
|
{ keyword: 'deepseek', mode: 'includes' },
|
||||||
{ keyword: 'ds-chat', mode: 'segment' },
|
{ keyword: 'ds-chat', mode: 'segment' },
|
||||||
@@ -83,7 +77,6 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
{ keyword: 'moonshot', mode: 'includes' },
|
{ keyword: 'moonshot', mode: 'includes' },
|
||||||
{ keyword: 'kimi', mode: 'includes' },
|
{ keyword: 'kimi', mode: 'includes' },
|
||||||
],
|
],
|
||||||
// 零一万物: startsWithAny('yi-') + matchesBoundary
|
|
||||||
'零一万物': [
|
'零一万物': [
|
||||||
{ keyword: 'yi-', mode: 'startsWith' },
|
{ keyword: 'yi-', mode: 'startsWith' },
|
||||||
{ keyword: 'yi', mode: 'boundary' },
|
{ keyword: 'yi', mode: 'boundary' },
|
||||||
@@ -103,19 +96,16 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
'豆包': [
|
'豆包': [
|
||||||
{ keyword: 'doubao', mode: 'includes' },
|
{ keyword: 'doubao', mode: 'includes' },
|
||||||
],
|
],
|
||||||
// MiniMax: includesAny + hasExactSegment
|
|
||||||
'MiniMax': [
|
'MiniMax': [
|
||||||
{ keyword: 'minimax', mode: 'includes' },
|
{ keyword: 'minimax', mode: 'includes' },
|
||||||
{ keyword: 'abab', mode: 'includes' },
|
{ keyword: 'abab', mode: 'includes' },
|
||||||
{ keyword: 'mini2.1', mode: 'segment' },
|
{ keyword: 'mini2.1', mode: 'segment' },
|
||||||
],
|
],
|
||||||
// Cohere: includesAny + startsWithAny('embed-')
|
|
||||||
'Cohere': [
|
'Cohere': [
|
||||||
{ keyword: 'command', mode: 'includes' },
|
{ keyword: 'command', mode: 'includes' },
|
||||||
{ keyword: 'c4ai-', mode: 'includes' },
|
{ keyword: 'c4ai-', mode: 'includes' },
|
||||||
{ keyword: 'embed-', mode: 'startsWith' },
|
{ keyword: 'embed-', mode: 'startsWith' },
|
||||||
],
|
],
|
||||||
// Microsoft: includesAny + hasExactSegment('phi4')
|
|
||||||
'Microsoft': [
|
'Microsoft': [
|
||||||
{ keyword: 'microsoft/', mode: 'includes' },
|
{ keyword: 'microsoft/', mode: 'includes' },
|
||||||
{ keyword: 'phi-', mode: 'includes' },
|
{ keyword: 'phi-', mode: 'includes' },
|
||||||
@@ -125,13 +115,11 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
'xAI': [
|
'xAI': [
|
||||||
{ keyword: 'grok', mode: 'includes' },
|
{ keyword: 'grok', mode: 'includes' },
|
||||||
],
|
],
|
||||||
// 阶跃星辰: includesAny('stepfun') + startsWithAny('step-', 'step3')
|
|
||||||
'阶跃星辰': [
|
'阶跃星辰': [
|
||||||
{ keyword: 'stepfun', mode: 'includes' },
|
{ keyword: 'stepfun', mode: 'includes' },
|
||||||
{ keyword: 'step-', mode: 'startsWith' },
|
{ keyword: 'step-', mode: 'startsWith' },
|
||||||
{ keyword: 'step3', mode: 'startsWith' },
|
{ keyword: 'step3', mode: 'startsWith' },
|
||||||
],
|
],
|
||||||
// Stability: includesAny + startsWithAny('sd3')
|
|
||||||
'Stability': [
|
'Stability': [
|
||||||
{ keyword: 'flux', mode: 'includes' },
|
{ keyword: 'flux', mode: 'includes' },
|
||||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
{ keyword: 'stablediffusion', mode: 'includes' },
|
||||||
@@ -139,7 +127,6 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
{ keyword: 'sdxl', mode: 'includes' },
|
{ keyword: 'sdxl', mode: 'includes' },
|
||||||
{ keyword: 'sd3', mode: 'startsWith' },
|
{ keyword: 'sd3', mode: 'startsWith' },
|
||||||
],
|
],
|
||||||
// NVIDIA: includesAny + startsWithAny('nv-')
|
|
||||||
'NVIDIA': [
|
'NVIDIA': [
|
||||||
{ keyword: 'nvidia/', mode: 'includes' },
|
{ keyword: 'nvidia/', mode: 'includes' },
|
||||||
{ keyword: 'nvclip', mode: 'includes' },
|
{ keyword: 'nvclip', mode: 'includes' },
|
||||||
@@ -158,7 +145,6 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
{ keyword: 'baai/', mode: 'includes' },
|
{ keyword: 'baai/', mode: 'includes' },
|
||||||
{ keyword: 'bge-', mode: 'includes' },
|
{ keyword: 'bge-', mode: 'includes' },
|
||||||
],
|
],
|
||||||
// ByteDance: includesAny + startsWithAny('wan-', 'kat-')
|
|
||||||
'ByteDance': [
|
'ByteDance': [
|
||||||
{ keyword: 'bytedance', mode: 'includes' },
|
{ keyword: 'bytedance', mode: 'includes' },
|
||||||
{ keyword: 'seed-oss', mode: 'includes' },
|
{ keyword: 'seed-oss', mode: 'includes' },
|
||||||
@@ -171,12 +157,10 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
'InternLM': [
|
'InternLM': [
|
||||||
{ keyword: 'internlm', mode: 'includes' },
|
{ keyword: 'internlm', mode: 'includes' },
|
||||||
],
|
],
|
||||||
// Midjourney: includesAny + startsWithAny('mj_')
|
|
||||||
'Midjourney': [
|
'Midjourney': [
|
||||||
{ keyword: 'midjourney', mode: 'includes' },
|
{ keyword: 'midjourney', mode: 'includes' },
|
||||||
{ keyword: 'mj_', mode: 'startsWith' },
|
{ keyword: 'mj_', mode: 'startsWith' },
|
||||||
],
|
],
|
||||||
// DeepL: startsWithAny('deepl-') + includesAny('deepl/')
|
|
||||||
'DeepL': [
|
'DeepL': [
|
||||||
{ keyword: 'deepl/', mode: 'includes' },
|
{ keyword: 'deepl/', mode: 'includes' },
|
||||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
{ keyword: 'deepl-', mode: 'startsWith' },
|
||||||
@@ -186,16 +170,92 @@ const BRAND_RULES: Record<string, KeywordRule[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 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<string>();
|
||||||
|
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[] {
|
export function getAllBrandNames(): string[] {
|
||||||
return Object.keys(BRAND_RULES);
|
return Object.keys(BRAND_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect the rules for the blocked brands.
|
|
||||||
*/
|
|
||||||
export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
|
export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
|
||||||
const rules: KeywordRule[] = [];
|
const rules: KeywordRule[] = [];
|
||||||
for (const brand of blockedBrands) {
|
for (const brand of blockedBrands) {
|
||||||
@@ -205,40 +265,9 @@ export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
|
|||||||
return rules;
|
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 {
|
export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean {
|
||||||
const lower = modelName.trim().toLowerCase();
|
if (!modelName || rules.length === 0) return false;
|
||||||
if (!lower || rules.length === 0) return false;
|
const ctx = buildMatchContext(modelName);
|
||||||
const segments = getSegments(lower);
|
if (!ctx.raw) return false;
|
||||||
return rules.some((rule) => matchesRule(lower, segments, rule));
|
return rules.some((rule) => matchesRule(ctx, rule));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,6 @@ export default function Sites() {
|
|||||||
const highlightTimerRef = useRef<number | null>(null);
|
const highlightTimerRef = useRef<number | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [disabledModels, setDisabledModels] = useState<string[]>([]);
|
const [disabledModels, setDisabledModels] = useState<string[]>([]);
|
||||||
const [disabledModelInput, setDisabledModelInput] = useState('');
|
|
||||||
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
|
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
|
||||||
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
@@ -357,7 +356,6 @@ export default function Sites() {
|
|||||||
// Load disabled models and available models for this site
|
// Load disabled models and available models for this site
|
||||||
setDisabledModelsLoading(true);
|
setDisabledModelsLoading(true);
|
||||||
setDisabledModels([]);
|
setDisabledModels([]);
|
||||||
setDisabledModelInput('');
|
|
||||||
setAvailableModels([]);
|
setAvailableModels([]);
|
||||||
setDisabledModelSearch('');
|
setDisabledModelSearch('');
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -372,22 +370,6 @@ export default function Sites() {
|
|||||||
.finally(() => setDisabledModelsLoading(false));
|
.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 () => {
|
const handleSaveDisabledModels = async () => {
|
||||||
if (!editor || editor.mode !== 'edit') return;
|
if (!editor || editor.mode !== 'edit') return;
|
||||||
setDisabledModelsSaving(true);
|
setDisabledModelsSaving(true);
|
||||||
@@ -1039,57 +1021,6 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 (
|
|
||||||
<div style={{ marginBottom: 10 }}>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>手动添加的禁用模型(不在站点已发现列表中):</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
||||||
{manualOnly.map((model) => (
|
|
||||||
<span
|
|
||||||
key={model}
|
|
||||||
className="badge badge-muted"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
|
|
||||||
>
|
|
||||||
{model}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveDisabledModel(model)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
|
||||||
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
|
|
||||||
}}
|
|
||||||
title={`移除 ${model}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Manual input for models not in the list */}
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
placeholder="手动输入模型名称,如 gpt-4o"
|
|
||||||
value={disabledModelInput}
|
|
||||||
onChange={(e) => 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)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button onClick={handleAddDisabledModel} className="btn btn-ghost" style={{ padding: '8px 14px', fontSize: 12, border: '1px solid var(--color-border)' }}>
|
|
||||||
添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveDisabledModels}
|
onClick={handleSaveDisabledModels}
|
||||||
|
|||||||
Reference in New Issue
Block a user