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] 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 {