Files

1 line
2.0 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ccbud/ccbud", "files": {"src/renderer/renderer.js": {"4d9a29febd2622435a6649c5ec75ca6dcc91130f": {"t": 1785746684.4498656, "n": 103611, "h": "d7415a2f6a1a37f1", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic/v1', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n google: { name: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-3.5-flash', smallFastModel: 'gemini-3.1-flash-lite', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', google: 'Google AI Studio', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { google: ['google ai studio', 'gemini', 'generativelanguage'], kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n const asset = file === 'google' ? 'google-ai-studio.png' : `${file}.svg`;\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${asset}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('err.noProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n applyConvFont();\n renderConvFontControl();\n}\n\n/* ---------- 会话正文字号 (Sessions message text size) ---------- */\n// The 对话 message timeline scales through one root CSS var (--conv-fs, a factor of the 13px\n// base — see input.css). Persisted as config.convFontPx: absent/13 = default, 15 = 大,\n// 17 = 特大, anything else = custom.\nconst CONV_FONT_BASE = 13;\nconst CONV_FONT_PRESETS = { large: 15, xlarge: 17 };\nconst CONV_FONT_MIN = 10, CONV_FONT_MAX = 24;\nlet convFontCustomOpen = false; // 自定义 clicked but value not (yet) diverging from a preset\n\nfunction convFontPx() {\n const v = config.convFontPx;\n const n = Number(v);\n if (v == null || v === '' || !Number.isFinite(n) || n <= 0) return CONV_FONT_BASE;\n return Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, Math.round(n)));\n}\nfunction applyConvFont() {\n const px = convFontPx();\n if (px === CONV_FONT_BASE) document.documentElement.style.removeProperty('--conv-fs');\n else document.documentElement.style.setProperty('--conv-fs', String(px / CONV_FONT_BASE));\n}\nfunction convFontMode(px) {\n if (convFontCustomOpen) return 'custom';\n if (px === CONV_FONT_PRESETS.large) return 'large';\n if (px === CONV_FONT_PRESETS.xlarge) return 'xlarge';\n return px === CONV_FONT_BASE ? 'default' : 'custom';\n}\nfunction renderConvFontControl() {\n const seg = $('fConvFontSeg');\n if (!seg) return;\n const px = convFontPx();\n const mode = convFontMode(px);\n seg.querySelectorAll('.seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.cfs === mode));\n const row = $('convFontCustomRow');\n if (row) row.classList.toggle('hidden', mode !== 'custom');\n const input = $('fConvFontPx');\n if (input && document.activeElement !== input) input.value = px;\n const chip = $('convFontPreviewPx');\n if (chip) chip.textContent = px + 'px';\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\n/* ---------- plugins (sidecar coding-agent backends) ---------- */\nconst PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md';\nconst pluginActionsById = {}; // pluginId -> declared actions (for the form modal)\nlet pluginListWired = false;\nasync function loadPlugins() {\n const list = $('pluginList');\n if (!list) return;\n if (!pluginListWired) {\n pluginListWired = true;\n list.addEventListener('click', onPluginAction);\n const bd = $('linkPluginDocs');\n if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} });\n const bi = $('btnPluginInstall');\n if (bi) bi.addEventListener('click', async () => {\n try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); }\n catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); }\n await loadPlugins();\n });\n const bo = $('btnPluginOpenDir');\n if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} });\n const bg = $('btnPluginGit');\n if (bg) bg.addEventListener('click', openPluginGitModal);\n const bgc = $('btnGitCancel');\n if (bgc) bgc.addEventListener('click', closePluginGitModal);\n const bgm = $('pluginGitModal');\n if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); });\n const bgo = $('btnPluginGitGo');\n if (bgo) bgo.addEventListener('click', importFromGit);\n const gu = $('pluginGitUrl');\n if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); });\n }\n let plugins = [];\n try { plugins = await api.pluginList(); }\n catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); }\n const arr = Array.isArray(plugins) ? plugins : [];\n renderPlugins(arr);\n for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); }\n}\nasync function checkPluginUpdate(id) {\n let r;\n try { r = await api.pluginCheckUpdate(id); } catch (_) { return; }\n if (!r || !r.updateAvailable) return;\n const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id;\n const slot = document.querySelector('[data-update-slot=\"' + sel + '\"]');\n if (slot) slot.innerHTML = '<button class=\"text-[10.5px] font-semibold text-amber bg-amber-soft rounded-full px-1.75 py-0.25 border-none cursor-pointer hover:opacity-85\" data-plugin-update=\"' + escapeHtml(id) + '\" title=\"' + escapeHtml(I18n.t('plugins.updateTitle', { ver: r.latest || '' })) + '\">↑ v' + escapeHtml(r.latest || '') + '</button>';\n}\nasync function importFromGit() {\n const u = $('pluginGitUrl');\n const url = ((u && u.value) || '').trim();\n if (!url) return;\n closePluginGitModal();\n showPluginBusy(I18n.t('plugins.importing'));\n try {\n const r = await api.pluginInstallGit(url);\n hidePluginBusy();\n if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok');\n } catch (e) {\n hidePluginBusy();\n showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err');\n }\n await loadPlugins();\n}\nfunction renderPlugins(plugins) {\n const list = $('pluginList');\n if (!list) return;\n const empty = $('emptyPlugins');\n if (empty) empty.classList.toggle('hidden', plugins.length > 0);\n list.innerHTML = '';\n for (const p of plugins) {\n const running = !!p.running;\n const auth = p.auth || {};\n const st = auth.state || '';\n const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : ''))\n : st === 'expired' ? I18n.t('plugins.authExpired')\n : st === 'logged_out' ? I18n.t('plugins.authLoggedOut')\n : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning');\n const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption');\n const iconData = renderProviderIcon(p.name, p.icon);\n const dot = `<span class=\"inline-block w-1.5 h-1.5 rounded-full ${running ? 'bg-green' : 'bg-border-strong'} shrink-0\"></span>`;\n const toggleBtn = `<button class=\"btn btn-sm ${running ? 'bg-red-soft text-red border border-red/18' : 'bg-green-soft text-green border border-green/18'} rounded-md px-2.75 py-1.25 font-semibold text-[11px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\" data-plugin-toggle=\"${escapeHtml(p.id)}\" data-enabled=\"${running ? '1' : '0'}\">${running ? escapeHtml(I18n.t('plugins.disable')) : escapeHtml(I18n.t('plugins.enable'))}</button>`;\n const delBtn = `<button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red\" title=\"${escapeHtml(I18n.t('plugins.deleteTitle'))}\" data-plugin-uninstall=\"${escapeHtml(p.id)}\" data-plugin-name=\"${escapeHtml(p.name || p.id)}\">${I.trash || '⌫'}</button>`;\n // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest.\n const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => {\n if (!a || !a.id) return '';\n // Links open in a browser and never touch the plugin, so they don't need it\n // running; form/call actions default to requiring a running plugin.\n const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false);\n const disabled = needsRun && !running;\n const label = escapeHtml(a.label || a.id);\n return `<button class=\"btn btn-sm bg-bg-elev text-fg border border-border-custom rounded-md px-2.5 py-1.25 font-medium text-[11px] leading-none cursor-pointer hover:bg-chip-bg hover:border-border-strong active:scale-[0.985] disabled:opacity-45 disabled:cursor-not-allowed\" data-plugin-actionbtn=\"${escapeHtml(p.id)}\" data-action-id=\"${escapeHtml(a.id)}\" data-action-kind=\"${escapeHtml(a.kind || 'call')}\"${a.url ? ` data-action-url=\"${escapeHtml(a.url)}\"` : ''}${disabled ? ' disabled' : ''}>${label}</button>`;\n }).join('');\n pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : [];\n const el = document.createElement('div');\n el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong';\n el.dataset.id = p.id;\n el.innerHTML = `\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name || p.id)} <span class=\"text-[10.5px] font-mono text-caption font-normal\">v${escapeHtml(p.version || '')}</span>${p.official ? ' <span class=\"text-[10px] font-semibold text-brand bg-brand-soft rounded-full px-1.5 py-0.25 shrink-0\" title=\"' + escapeHtml(I18n.t('plugins.trusted')) + '\">' + escapeHtml(I18n.t('plugins.trusted')) + '</span>' : ''}<span data-update-slot=\"${escapeHtml(p.id)}\"></span> <span class=\"proto-badge proto-badge-xlate\">${escapeHtml(p.protocol || '')}</span></div>\n <div class=\"mt-0.5 text-xs text-caption truncate\">${escapeHtml(p.description || '')}</div>\n <div class=\"mt-1 flex items-center gap-1.5 text-[11.5px] ${authColor}\" data-plugin-status=\"${escapeHtml(p.id)}\">${dot}<span>${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}</span></div>\n </div>\n <div class=\"flex items-center gap-1.5 shrink-0\">${actionBtns}${toggleBtn}${delBtn}</div>`;\n list.appendChild(el);\n }\n}\nasync function onPluginAction(e) {\n const actionBtn = e.target.closest('[data-plugin-actionbtn]');\n if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; }\n const toggle = e.target.closest('[data-plugin-toggle]');\n const uninstall = e.target.closest('[data-plugin-uninstall]');\n const update = e.target.closest('[data-plugin-update]');\n const btn = toggle || uninstall || update;\n if (!btn) return;\n btn.disabled = true;\n try {\n if (toggle) {\n const enabling = toggle.dataset.enabled !== '1';\n // Starting a sidecar spawns a process and health-gates it (can take a\n // beat), so give the button an immediate spinner + \"Starting…\".\n if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting'));\n markPluginCardBusy(toggle);\n await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling);\n config = await api.getConfig(); // enabling adds a provider, disabling removes it\n renderProviders();\n } else if (uninstall) {\n const ok = await confirmDialog({\n title: I18n.t('plugins.deleteTitle'),\n message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }),\n confirmText: I18n.t('plugins.confirmDelete'),\n danger: true,\n });\n if (!ok) { uninstall.disabled = false; return; }\n const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall);\n if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); }\n } else if (update) {\n update.disabled = true;\n await api.pluginUpdate(update.dataset.pluginUpdate);\n config = await api.getConfig(); renderProviders();\n }\n } catch (err) {\n showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err');\n }\n await loadPlugins();\n}\nfunction openPluginGitModal() {\n const m = $('pluginGitModal'); if (!m) return;\n m.classList.remove('hidden');\n const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); }\n}\nfunction closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); }\n// Full-screen blocking overlay shown while a git clone/build runs — the user\n// cannot interact with the rest of the app until it finishes.\nfunction showPluginBusy(text) {\n let ov = document.getElementById('pluginBusy');\n if (!ov) {\n ov = document.createElement('div');\n ov.id = 'pluginBusy';\n ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md';\n ov.style.background = 'rgba(0,0,0,0.45)';\n ov.innerHTML = '<div class=\"animate-spin\" style=\"width:34px;height:34px;border:3px solid rgba(255,255,255,0.28);border-top-color:#fff;border-radius:50%\"></div><p id=\"pluginBusyText\" style=\"color:#fff;margin-top:14px;font-size:13px;font-weight:600\"></p>';\n document.body.appendChild(ov);\n }\n const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || '';\n ov.style.display = 'flex';\n}\nfunction hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; }\n\n// Small inline spinner used for per-button loading (enable/start).\nfunction pluginSpinner() {\n return '<span class=\"animate-spin inline-block w-3 h-3 rounded-full border-[1.5px] border-current border-t-transparent align-[-2px]\"></span>';\n}\n// Turn a button into a busy state: spinner + text, disabled. Reset happens on the\n// next renderPlugins() (loadPlugins re-renders the whole list from fresh status).\nfunction setPluginBtnBusy(btn, text) {\n if (!btn) return;\n btn.disabled = true;\n btn.innerHTML = `<span class=\"inline-flex items-center gap-1.25\">${pluginSpinner()}${escapeHtml(text || '')}</span>`;\n}\n// Dim the card and swap its status line to a \"starting\" spinner while the sidecar\n// spins up, so the whole row reads as in-progress (not just the button).\nfunction markPluginCardBusy(btn) {\n const card = btn && btn.closest('.plugin');\n if (!card) return;\n card.classList.add('opacity-60', 'pointer-events-none');\n const line = card.querySelector('[data-plugin-status]');\n if (line) line.innerHTML = `${pluginSpinner()}<span>${escapeHtml(I18n.t('plugins.starting'))}</span>`;\n}\n\n// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ----\nasync function runPluginDeclaredAction(btn) {\n if (btn.disabled) return;\n const pid = btn.dataset.pluginActionbtn;\n const actionId = btn.dataset.actionId;\n const kind = btn.dataset.actionKind || 'call';\n if (kind === 'link') {\n const url = btn.dataset.actionUrl;\n if (url) { try { api.openExternal(url); } catch (_) {} }\n return;\n }\n const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId };\n if (kind === 'form') { await openPluginActionForm(pid, action); return; }\n // kind === 'call': fire-and-report, with an optional confirm gate\n // (confirmDialog, not window.confirm — the Tauri webview never shows the latter)\n if (action.confirm) {\n const ok = await confirmDialog({\n title: action.label || action.id,\n message: action.confirm,\n confirmText: action.label || action.id,\n });\n if (!ok) return;\n }\n btn.disabled = true;\n try {\n const r = await api.pluginAction(pid, actionId, {});\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n } catch (err) {\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n btn.disabled = false;\n await loadPlugins();\n}\n\n// Build one form control from a field spec. Recognized types: text (default),\n// number, password, textarea, select, checkbox.\nfunction pluginFieldControl(f, val) {\n const key = escapeHtml(f.key);\n const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary';\n const v = (val != null ? val : (f.default != null ? f.default : ''));\n if (f.type === 'select') {\n const opts = (Array.isArray(f.options) ? f.options : []).map((o) => {\n const ov = (o && typeof o === 'object') ? o.value : o;\n const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;\n const sel = String(ov) === String(v) ? ' selected' : '';\n return `<option value=\"${escapeHtml(String(ov))}\"${sel}>${escapeHtml(String(ol))}</option>`;\n }).join('');\n return `<select data-field-key=\"${key}\" class=\"${cls}\">${opts}</select>`;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return `<input type=\"checkbox\" data-field-key=\"${key}\" ${on ? 'checked' : ''} class=\"w-4 h-4 accent-brand self-start\" />`;\n }\n if (f.type === 'textarea') {\n return `<textarea data-field-key=\"${key}\" rows=\"3\" class=\"${cls}\" placeholder=\"${escapeHtml(f.placeholder || '')}\">${escapeHtml(String(v))}</textarea>`;\n }\n const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';\n const mono = type === 'text' || type === 'number' ? ' font-mono' : '';\n const minmax = f.type === 'number'\n ? `${f.min != null ? ` min=\"${escapeHtml(String(f.min))}\"` : ''}${f.max != null ? ` max=\"${escapeHtml(String(f.max))}\"` : ''}`\n : '';\n return `<input type=\"${type}\" data-field-key=\"${key}\" value=\"${escapeHtml(String(v))}\" placeholder=\"${escapeHtml(f.placeholder || '')}\"${minmax} class=\"${cls}${mono}\" />`;\n}\n\n// Read the form back into a values object, coercing/validating by field type.\n// Returns null (and focuses the offending control) if a required/number check fails.\nfunction collectPluginFormValues(root, fields) {\n const out = {};\n for (const f of fields) {\n const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key;\n const el = root.querySelector(`[data-field-key=\"${sel}\"]`);\n if (!el) continue;\n let v = f.type === 'checkbox' ? !!el.checked : el.value;\n if (f.type === 'number') {\n if (v === '' || v == null) {\n v = null;\n } else {\n const n = Number(v);\n if (Number.isNaN(n)) { el.focus(); return null; }\n v = n;\n }\n }\n if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; }\n out[f.key] = v;\n }\n return out;\n}\n\n// Open a modal built from a plugin action's field specs; prefill from the plugin,\n// then POST the collected values back through the host.\nasync function openPluginActionForm(pid, action) {\n const fields = Array.isArray(action.fields) ? action.fields : [];\n let values = {};\n if (action.loadOnOpen !== false) {\n try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; }\n catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); }\n }\n const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove();\n const ov = document.createElement('div');\n ov.id = 'pluginActionModal';\n ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md';\n const rows = fields.map((f) => `\n <label class=\"field flex flex-col gap-1.25\">\n <span class=\"field-label text-[11px] font-semibold text-caption uppercase tracking-[0.03em]\">${escapeHtml(f.label || f.key)}</span>\n ${pluginFieldControl(f, values[f.key])}\n ${f.help ? `<span class=\"text-[11px] text-caption leading-[1.4]\">${escapeHtml(f.help)}</span>` : ''}\n </label>`).join('');\n ov.innerHTML = `\n <div class=\"sheet w-[460px] max-w-[92vw] bg-bg-elev backdrop-blur-[40px] border border-window-border rounded-2xl shadow-[0_24px_64px_rgba(0,0,0,0.18)] flex flex-col\">\n <header class=\"flex items-center gap-2 p-[14px_18px] border-b border-border-custom\">\n <h3 class=\"text-[14px] font-semibold tracking-tight\">${escapeHtml(action.label || action.id)}</h3>\n </header>\n <div class=\"p-[18px_20px] flex flex-col gap-3\">\n ${rows}\n <div class=\"flex justify-end gap-2 mt-1\">\n <button data-act=\"cancel\" class=\"btn bg-bg-elev text-muted border border-border-custom rounded-md px-3 py-2 font-medium text-[12px] leading-none cursor-pointer hover:bg-chip-bg hover:text-fg\">${escapeHtml(I18n.t('plugins.cancel'))}</button>\n <button data-act=\"submit\" class=\"btn bg-brand text-white border-none rounded-md px-3.5 py-2 font-semibold text-[12px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\">${escapeHtml(action.submitLabel || I18n.t('plugins.save'))}</button>\n </div>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const close = () => ov.remove();\n ov.addEventListener('click', (e) => { if (e.target === ov) close(); });\n ov.querySelector('[data-act=\"cancel\"]').addEventListener('click', close);\n ov.querySelector('[data-act=\"submit\"]').addEventListener('click', async () => {\n const vals = collectPluginFormValues(ov, fields);\n if (vals == null) return;\n const submitBtn = ov.querySelector('[data-act=\"submit\"]');\n submitBtn.disabled = true;\n try {\n const r = await api.pluginAction(pid, action.id, vals);\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n close();\n await loadPlugins();\n } catch (err) {\n submitBtn.disabled = false;\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n });\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n plugins: 'view-plugins',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'plugins') loadPlugins();\n if (view === 'monitor') refreshGatewayLog();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n // 会话正文字号: preset segments persist directly; 自定义 opens the px input (which persists\n // on change). The preview + open Sessions view update live through the --conv-fs root var.\n const convFontSeg = $('fConvFontSeg');\n if (convFontSeg) convFontSeg.addEventListener('click', (e) => {\n const b = e.target.closest('.seg-btn');\n if (!b) return;\n const mode = b.dataset.cfs;\n if (mode === 'custom') {\n convFontCustomOpen = true;\n renderConvFontControl();\n const input = $('fConvFontPx');\n if (input) { input.focus(); input.select(); }\n return;\n }\n convFontCustomOpen = false;\n const px = mode === 'large' ? CONV_FONT_PRESETS.large : mode === 'xlarge' ? CONV_FONT_PRESETS.xlarge : CONV_FONT_BASE;\n persist({ convFontPx: px === CONV_FONT_BASE ? null : px });\n });\n const fConvFontPx = $('fConvFontPx');\n if (fConvFontPx) {\n const commit = () => {\n const n = Math.round(Number(fConvFontPx.value));\n if (!Number.isFinite(n)) { fConvFontPx.value = convFontPx(); return; }\n const px = Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, n));\n fConvFontPx.value = px;\n persist({ convFontPx: px === CONV_FONT_BASE ? null : px });\n };\n fConvFontPx.addEventListener('change', commit);\n fConvFontPx.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } });\n }\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) {\n // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS).\n const ok = await confirmDialog({\n title: I18n.t('providers.delete'),\n message: I18n.t('providers.confirmDelete'),\n confirmText: I18n.t('providers.delete'),\n danger: true,\n });\n if (ok) { config = await api.deleteProvider(btn.dataset.del); renderAll(); }\n return;\n }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.running) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n try {\n config = await api.setActive(card.dataset.id);\n } catch (err) {\n const msg = (err && err.message) || String(err);\n showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err');\n return;\n }\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const testedProvider = collectProvider();\n const res = await api.testProvider(testedProvider);\n if (res.ok && res.baseUrl && $('fBaseUrl').value.trim() === testedProvider.baseUrl) {\n $('fBaseUrl').value = res.baseUrl;\n }\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n if (api.onConfigChanged) api.onConfigChanged(async (next) => {\n const previous = config;\n const previousProvider = editingId && previous.providers.find((p) => p.id === editingId);\n const baseUrlInput = editingId ? $('fBaseUrl') : null;\n const baseUrlWasUnchanged = !!(previousProvider && baseUrlInput && baseUrlInput.value === (previousProvider.baseUrl || ''));\n config = next && Array.isArray(next.providers) ? next : await api.getConfig();\n if (baseUrlWasUnchanged) {\n const updatedProvider = config.providers.find((p) => p.id === editingId);\n if (updatedProvider) baseUrlInput.value = updatedProvider.baseUrl || '';\n }\n renderAll();\n });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "e9137d9e90f553e2511b6cb5099381fc5cb57b03": {"t": 1785766810.491431, "n": 103611, "h": "d7415a2f6a1a37f1", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic/v1', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n google: { name: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-3.5-flash', smallFastModel: 'gemini-3.1-flash-lite', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', google: 'Google AI Studio', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { google: ['google ai studio', 'gemini', 'generativelanguage'], kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n const asset = file === 'google' ? 'google-ai-studio.png' : `${file}.svg`;\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${asset}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('err.noProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n applyConvFont();\n renderConvFontControl();\n}\n\n/* ---------- 会话正文字号 (Sessions message text size) ---------- */\n// The 对话 message timeline scales through one root CSS var (--conv-fs, a factor of the 13px\n// base — see input.css). Persisted as config.convFontPx: absent/13 = default, 15 = 大,\n// 17 = 特大, anything else = custom.\nconst CONV_FONT_BASE = 13;\nconst CONV_FONT_PRESETS = { large: 15, xlarge: 17 };\nconst CONV_FONT_MIN = 10, CONV_FONT_MAX = 24;\nlet convFontCustomOpen = false; // 自定义 clicked but value not (yet) diverging from a preset\n\nfunction convFontPx() {\n const v = config.convFontPx;\n const n = Number(v);\n if (v == null || v === '' || !Number.isFinite(n) || n <= 0) return CONV_FONT_BASE;\n return Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, Math.round(n)));\n}\nfunction applyConvFont() {\n const px = convFontPx();\n if (px === CONV_FONT_BASE) document.documentElement.style.removeProperty('--conv-fs');\n else document.documentElement.style.setProperty('--conv-fs', String(px / CONV_FONT_BASE));\n}\nfunction convFontMode(px) {\n if (convFontCustomOpen) return 'custom';\n if (px === CONV_FONT_PRESETS.large) return 'large';\n if (px === CONV_FONT_PRESETS.xlarge) return 'xlarge';\n return px === CONV_FONT_BASE ? 'default' : 'custom';\n}\nfunction renderConvFontControl() {\n const seg = $('fConvFontSeg');\n if (!seg) return;\n const px = convFontPx();\n const mode = convFontMode(px);\n seg.querySelectorAll('.seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.cfs === mode));\n const row = $('convFontCustomRow');\n if (row) row.classList.toggle('hidden', mode !== 'custom');\n const input = $('fConvFontPx');\n if (input && document.activeElement !== input) input.value = px;\n const chip = $('convFontPreviewPx');\n if (chip) chip.textContent = px + 'px';\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\n/* ---------- plugins (sidecar coding-agent backends) ---------- */\nconst PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md';\nconst pluginActionsById = {}; // pluginId -> declared actions (for the form modal)\nlet pluginListWired = false;\nasync function loadPlugins() {\n const list = $('pluginList');\n if (!list) return;\n if (!pluginListWired) {\n pluginListWired = true;\n list.addEventListener('click', onPluginAction);\n const bd = $('linkPluginDocs');\n if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} });\n const bi = $('btnPluginInstall');\n if (bi) bi.addEventListener('click', async () => {\n try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); }\n catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); }\n await loadPlugins();\n });\n const bo = $('btnPluginOpenDir');\n if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} });\n const bg = $('btnPluginGit');\n if (bg) bg.addEventListener('click', openPluginGitModal);\n const bgc = $('btnGitCancel');\n if (bgc) bgc.addEventListener('click', closePluginGitModal);\n const bgm = $('pluginGitModal');\n if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); });\n const bgo = $('btnPluginGitGo');\n if (bgo) bgo.addEventListener('click', importFromGit);\n const gu = $('pluginGitUrl');\n if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); });\n }\n let plugins = [];\n try { plugins = await api.pluginList(); }\n catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); }\n const arr = Array.isArray(plugins) ? plugins : [];\n renderPlugins(arr);\n for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); }\n}\nasync function checkPluginUpdate(id) {\n let r;\n try { r = await api.pluginCheckUpdate(id); } catch (_) { return; }\n if (!r || !r.updateAvailable) return;\n const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id;\n const slot = document.querySelector('[data-update-slot=\"' + sel + '\"]');\n if (slot) slot.innerHTML = '<button class=\"text-[10.5px] font-semibold text-amber bg-amber-soft rounded-full px-1.75 py-0.25 border-none cursor-pointer hover:opacity-85\" data-plugin-update=\"' + escapeHtml(id) + '\" title=\"' + escapeHtml(I18n.t('plugins.updateTitle', { ver: r.latest || '' })) + '\">↑ v' + escapeHtml(r.latest || '') + '</button>';\n}\nasync function importFromGit() {\n const u = $('pluginGitUrl');\n const url = ((u && u.value) || '').trim();\n if (!url) return;\n closePluginGitModal();\n showPluginBusy(I18n.t('plugins.importing'));\n try {\n const r = await api.pluginInstallGit(url);\n hidePluginBusy();\n if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok');\n } catch (e) {\n hidePluginBusy();\n showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err');\n }\n await loadPlugins();\n}\nfunction renderPlugins(plugins) {\n const list = $('pluginList');\n if (!list) return;\n const empty = $('emptyPlugins');\n if (empty) empty.classList.toggle('hidden', plugins.length > 0);\n list.innerHTML = '';\n for (const p of plugins) {\n const running = !!p.running;\n const auth = p.auth || {};\n const st = auth.state || '';\n const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : ''))\n : st === 'expired' ? I18n.t('plugins.authExpired')\n : st === 'logged_out' ? I18n.t('plugins.authLoggedOut')\n : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning');\n const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption');\n const iconData = renderProviderIcon(p.name, p.icon);\n const dot = `<span class=\"inline-block w-1.5 h-1.5 rounded-full ${running ? 'bg-green' : 'bg-border-strong'} shrink-0\"></span>`;\n const toggleBtn = `<button class=\"btn btn-sm ${running ? 'bg-red-soft text-red border border-red/18' : 'bg-green-soft text-green border border-green/18'} rounded-md px-2.75 py-1.25 font-semibold text-[11px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\" data-plugin-toggle=\"${escapeHtml(p.id)}\" data-enabled=\"${running ? '1' : '0'}\">${running ? escapeHtml(I18n.t('plugins.disable')) : escapeHtml(I18n.t('plugins.enable'))}</button>`;\n const delBtn = `<button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red\" title=\"${escapeHtml(I18n.t('plugins.deleteTitle'))}\" data-plugin-uninstall=\"${escapeHtml(p.id)}\" data-plugin-name=\"${escapeHtml(p.name || p.id)}\">${I.trash || '⌫'}</button>`;\n // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest.\n const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => {\n if (!a || !a.id) return '';\n // Links open in a browser and never touch the plugin, so they don't need it\n // running; form/call actions default to requiring a running plugin.\n const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false);\n const disabled = needsRun && !running;\n const label = escapeHtml(a.label || a.id);\n return `<button class=\"btn btn-sm bg-bg-elev text-fg border border-border-custom rounded-md px-2.5 py-1.25 font-medium text-[11px] leading-none cursor-pointer hover:bg-chip-bg hover:border-border-strong active:scale-[0.985] disabled:opacity-45 disabled:cursor-not-allowed\" data-plugin-actionbtn=\"${escapeHtml(p.id)}\" data-action-id=\"${escapeHtml(a.id)}\" data-action-kind=\"${escapeHtml(a.kind || 'call')}\"${a.url ? ` data-action-url=\"${escapeHtml(a.url)}\"` : ''}${disabled ? ' disabled' : ''}>${label}</button>`;\n }).join('');\n pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : [];\n const el = document.createElement('div');\n el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong';\n el.dataset.id = p.id;\n el.innerHTML = `\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name || p.id)} <span class=\"text-[10.5px] font-mono text-caption font-normal\">v${escapeHtml(p.version || '')}</span>${p.official ? ' <span class=\"text-[10px] font-semibold text-brand bg-brand-soft rounded-full px-1.5 py-0.25 shrink-0\" title=\"' + escapeHtml(I18n.t('plugins.trusted')) + '\">' + escapeHtml(I18n.t('plugins.trusted')) + '</span>' : ''}<span data-update-slot=\"${escapeHtml(p.id)}\"></span> <span class=\"proto-badge proto-badge-xlate\">${escapeHtml(p.protocol || '')}</span></div>\n <div class=\"mt-0.5 text-xs text-caption truncate\">${escapeHtml(p.description || '')}</div>\n <div class=\"mt-1 flex items-center gap-1.5 text-[11.5px] ${authColor}\" data-plugin-status=\"${escapeHtml(p.id)}\">${dot}<span>${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}</span></div>\n </div>\n <div class=\"flex items-center gap-1.5 shrink-0\">${actionBtns}${toggleBtn}${delBtn}</div>`;\n list.appendChild(el);\n }\n}\nasync function onPluginAction(e) {\n const actionBtn = e.target.closest('[data-plugin-actionbtn]');\n if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; }\n const toggle = e.target.closest('[data-plugin-toggle]');\n const uninstall = e.target.closest('[data-plugin-uninstall]');\n const update = e.target.closest('[data-plugin-update]');\n const btn = toggle || uninstall || update;\n if (!btn) return;\n btn.disabled = true;\n try {\n if (toggle) {\n const enabling = toggle.dataset.enabled !== '1';\n // Starting a sidecar spawns a process and health-gates it (can take a\n // beat), so give the button an immediate spinner + \"Starting…\".\n if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting'));\n markPluginCardBusy(toggle);\n await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling);\n config = await api.getConfig(); // enabling adds a provider, disabling removes it\n renderProviders();\n } else if (uninstall) {\n const ok = await confirmDialog({\n title: I18n.t('plugins.deleteTitle'),\n message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }),\n confirmText: I18n.t('plugins.confirmDelete'),\n danger: true,\n });\n if (!ok) { uninstall.disabled = false; return; }\n const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall);\n if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); }\n } else if (update) {\n update.disabled = true;\n await api.pluginUpdate(update.dataset.pluginUpdate);\n config = await api.getConfig(); renderProviders();\n }\n } catch (err) {\n showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err');\n }\n await loadPlugins();\n}\nfunction openPluginGitModal() {\n const m = $('pluginGitModal'); if (!m) return;\n m.classList.remove('hidden');\n const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); }\n}\nfunction closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); }\n// Full-screen blocking overlay shown while a git clone/build runs — the user\n// cannot interact with the rest of the app until it finishes.\nfunction showPluginBusy(text) {\n let ov = document.getElementById('pluginBusy');\n if (!ov) {\n ov = document.createElement('div');\n ov.id = 'pluginBusy';\n ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md';\n ov.style.background = 'rgba(0,0,0,0.45)';\n ov.innerHTML = '<div class=\"animate-spin\" style=\"width:34px;height:34px;border:3px solid rgba(255,255,255,0.28);border-top-color:#fff;border-radius:50%\"></div><p id=\"pluginBusyText\" style=\"color:#fff;margin-top:14px;font-size:13px;font-weight:600\"></p>';\n document.body.appendChild(ov);\n }\n const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || '';\n ov.style.display = 'flex';\n}\nfunction hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; }\n\n// Small inline spinner used for per-button loading (enable/start).\nfunction pluginSpinner() {\n return '<span class=\"animate-spin inline-block w-3 h-3 rounded-full border-[1.5px] border-current border-t-transparent align-[-2px]\"></span>';\n}\n// Turn a button into a busy state: spinner + text, disabled. Reset happens on the\n// next renderPlugins() (loadPlugins re-renders the whole list from fresh status).\nfunction setPluginBtnBusy(btn, text) {\n if (!btn) return;\n btn.disabled = true;\n btn.innerHTML = `<span class=\"inline-flex items-center gap-1.25\">${pluginSpinner()}${escapeHtml(text || '')}</span>`;\n}\n// Dim the card and swap its status line to a \"starting\" spinner while the sidecar\n// spins up, so the whole row reads as in-progress (not just the button).\nfunction markPluginCardBusy(btn) {\n const card = btn && btn.closest('.plugin');\n if (!card) return;\n card.classList.add('opacity-60', 'pointer-events-none');\n const line = card.querySelector('[data-plugin-status]');\n if (line) line.innerHTML = `${pluginSpinner()}<span>${escapeHtml(I18n.t('plugins.starting'))}</span>`;\n}\n\n// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ----\nasync function runPluginDeclaredAction(btn) {\n if (btn.disabled) return;\n const pid = btn.dataset.pluginActionbtn;\n const actionId = btn.dataset.actionId;\n const kind = btn.dataset.actionKind || 'call';\n if (kind === 'link') {\n const url = btn.dataset.actionUrl;\n if (url) { try { api.openExternal(url); } catch (_) {} }\n return;\n }\n const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId };\n if (kind === 'form') { await openPluginActionForm(pid, action); return; }\n // kind === 'call': fire-and-report, with an optional confirm gate\n // (confirmDialog, not window.confirm — the Tauri webview never shows the latter)\n if (action.confirm) {\n const ok = await confirmDialog({\n title: action.label || action.id,\n message: action.confirm,\n confirmText: action.label || action.id,\n });\n if (!ok) return;\n }\n btn.disabled = true;\n try {\n const r = await api.pluginAction(pid, actionId, {});\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n } catch (err) {\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n btn.disabled = false;\n await loadPlugins();\n}\n\n// Build one form control from a field spec. Recognized types: text (default),\n// number, password, textarea, select, checkbox.\nfunction pluginFieldControl(f, val) {\n const key = escapeHtml(f.key);\n const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary';\n const v = (val != null ? val : (f.default != null ? f.default : ''));\n if (f.type === 'select') {\n const opts = (Array.isArray(f.options) ? f.options : []).map((o) => {\n const ov = (o && typeof o === 'object') ? o.value : o;\n const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;\n const sel = String(ov) === String(v) ? ' selected' : '';\n return `<option value=\"${escapeHtml(String(ov))}\"${sel}>${escapeHtml(String(ol))}</option>`;\n }).join('');\n return `<select data-field-key=\"${key}\" class=\"${cls}\">${opts}</select>`;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return `<input type=\"checkbox\" data-field-key=\"${key}\" ${on ? 'checked' : ''} class=\"w-4 h-4 accent-brand self-start\" />`;\n }\n if (f.type === 'textarea') {\n return `<textarea data-field-key=\"${key}\" rows=\"3\" class=\"${cls}\" placeholder=\"${escapeHtml(f.placeholder || '')}\">${escapeHtml(String(v))}</textarea>`;\n }\n const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';\n const mono = type === 'text' || type === 'number' ? ' font-mono' : '';\n const minmax = f.type === 'number'\n ? `${f.min != null ? ` min=\"${escapeHtml(String(f.min))}\"` : ''}${f.max != null ? ` max=\"${escapeHtml(String(f.max))}\"` : ''}`\n : '';\n return `<input type=\"${type}\" data-field-key=\"${key}\" value=\"${escapeHtml(String(v))}\" placeholder=\"${escapeHtml(f.placeholder || '')}\"${minmax} class=\"${cls}${mono}\" />`;\n}\n\n// Read the form back into a values object, coercing/validating by field type.\n// Returns null (and focuses the offending control) if a required/number check fails.\nfunction collectPluginFormValues(root, fields) {\n const out = {};\n for (const f of fields) {\n const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key;\n const el = root.querySelector(`[data-field-key=\"${sel}\"]`);\n if (!el) continue;\n let v = f.type === 'checkbox' ? !!el.checked : el.value;\n if (f.type === 'number') {\n if (v === '' || v == null) {\n v = null;\n } else {\n const n = Number(v);\n if (Number.isNaN(n)) { el.focus(); return null; }\n v = n;\n }\n }\n if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; }\n out[f.key] = v;\n }\n return out;\n}\n\n// Open a modal built from a plugin action's field specs; prefill from the plugin,\n// then POST the collected values back through the host.\nasync function openPluginActionForm(pid, action) {\n const fields = Array.isArray(action.fields) ? action.fields : [];\n let values = {};\n if (action.loadOnOpen !== false) {\n try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; }\n catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); }\n }\n const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove();\n const ov = document.createElement('div');\n ov.id = 'pluginActionModal';\n ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md';\n const rows = fields.map((f) => `\n <label class=\"field flex flex-col gap-1.25\">\n <span class=\"field-label text-[11px] font-semibold text-caption uppercase tracking-[0.03em]\">${escapeHtml(f.label || f.key)}</span>\n ${pluginFieldControl(f, values[f.key])}\n ${f.help ? `<span class=\"text-[11px] text-caption leading-[1.4]\">${escapeHtml(f.help)}</span>` : ''}\n </label>`).join('');\n ov.innerHTML = `\n <div class=\"sheet w-[460px] max-w-[92vw] bg-bg-elev backdrop-blur-[40px] border border-window-border rounded-2xl shadow-[0_24px_64px_rgba(0,0,0,0.18)] flex flex-col\">\n <header class=\"flex items-center gap-2 p-[14px_18px] border-b border-border-custom\">\n <h3 class=\"text-[14px] font-semibold tracking-tight\">${escapeHtml(action.label || action.id)}</h3>\n </header>\n <div class=\"p-[18px_20px] flex flex-col gap-3\">\n ${rows}\n <div class=\"flex justify-end gap-2 mt-1\">\n <button data-act=\"cancel\" class=\"btn bg-bg-elev text-muted border border-border-custom rounded-md px-3 py-2 font-medium text-[12px] leading-none cursor-pointer hover:bg-chip-bg hover:text-fg\">${escapeHtml(I18n.t('plugins.cancel'))}</button>\n <button data-act=\"submit\" class=\"btn bg-brand text-white border-none rounded-md px-3.5 py-2 font-semibold text-[12px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\">${escapeHtml(action.submitLabel || I18n.t('plugins.save'))}</button>\n </div>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const close = () => ov.remove();\n ov.addEventListener('click', (e) => { if (e.target === ov) close(); });\n ov.querySelector('[data-act=\"cancel\"]').addEventListener('click', close);\n ov.querySelector('[data-act=\"submit\"]').addEventListener('click', async () => {\n const vals = collectPluginFormValues(ov, fields);\n if (vals == null) return;\n const submitBtn = ov.querySelector('[data-act=\"submit\"]');\n submitBtn.disabled = true;\n try {\n const r = await api.pluginAction(pid, action.id, vals);\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n close();\n await loadPlugins();\n } catch (err) {\n submitBtn.disabled = false;\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n });\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n plugins: 'view-plugins',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'plugins') loadPlugins();\n if (view === 'monitor') refreshGatewayLog();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n // 会话正文字号: preset segments persist directly; 自定义 opens the px input (which persists\n // on change). The preview + open Sessions view update live through the --conv-fs root var.\n const convFontSeg = $('fConvFontSeg');\n if (convFontSeg) convFontSeg.addEventListener('click', (e) => {\n const b = e.target.closest('.seg-btn');\n if (!b) return;\n const mode = b.dataset.cfs;\n if (mode === 'custom') {\n convFontCustomOpen = true;\n renderConvFontControl();\n const input = $('fConvFontPx');\n if (input) { input.focus(); input.select(); }\n return;\n }\n convFontCustomOpen = false;\n const px = mode === 'large' ? CONV_FONT_PRESETS.large : mode === 'xlarge' ? CONV_FONT_PRESETS.xlarge : CONV_FONT_BASE;\n persist({ convFontPx: px === CONV_FONT_BASE ? null : px });\n });\n const fConvFontPx = $('fConvFontPx');\n if (fConvFontPx) {\n const commit = () => {\n const n = Math.round(Number(fConvFontPx.value));\n if (!Number.isFinite(n)) { fConvFontPx.value = convFontPx(); return; }\n const px = Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, n));\n fConvFontPx.value = px;\n persist({ convFontPx: px === CONV_FONT_BASE ? null : px });\n };\n fConvFontPx.addEventListener('change', commit);\n fConvFontPx.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } });\n }\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) {\n // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS).\n const ok = await confirmDialog({\n title: I18n.t('providers.delete'),\n message: I18n.t('providers.confirmDelete'),\n confirmText: I18n.t('providers.delete'),\n danger: true,\n });\n if (ok) { config = await api.deleteProvider(btn.dataset.del); renderAll(); }\n return;\n }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.running) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n try {\n config = await api.setActive(card.dataset.id);\n } catch (err) {\n const msg = (err && err.message) || String(err);\n showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err');\n return;\n }\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const testedProvider = collectProvider();\n const res = await api.testProvider(testedProvider);\n if (res.ok && res.baseUrl && $('fBaseUrl').value.trim() === testedProvider.baseUrl) {\n $('fBaseUrl').value = res.baseUrl;\n }\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n if (api.onConfigChanged) api.onConfigChanged(async (next) => {\n const previous = config;\n const previousProvider = editingId && previous.providers.find((p) => p.id === editingId);\n const baseUrlInput = editingId ? $('fBaseUrl') : null;\n const baseUrlWasUnchanged = !!(previousProvider && baseUrlInput && baseUrlInput.value === (previousProvider.baseUrl || ''));\n config = next && Array.isArray(next.providers) ? next : await api.getConfig();\n if (baseUrlWasUnchanged) {\n const updatedProvider = config.providers.find((p) => p.id === editingId);\n if (updatedProvider) baseUrlInput.value = updatedProvider.baseUrl || '';\n }\n renderAll();\n });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "195e5f2a817f7c413f19c973596ccdf5501303c4": {"t": 1785766812.306521, "n": 104688, "h": "e59a4dc4392c92a4", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic/v1', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n google: { name: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-3.5-flash', smallFastModel: 'gemini-3.1-flash-lite', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', google: 'Google AI Studio', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { google: ['google ai studio', 'gemini', 'generativelanguage'], kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n const asset = file === 'google' ? 'google-ai-studio.png' : `${file}.svg`;\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${asset}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\n/* ---------- plugins (sidecar coding-agent backends) ---------- */\nconst PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md';\nconst pluginActionsById = {}; // pluginId -> declared actions (for the form modal)\nlet pluginListWired = false;\nasync function loadPlugins() {\n const list = $('pluginList');\n if (!list) return;\n if (!pluginListWired) {\n pluginListWired = true;\n list.addEventListener('click', onPluginAction);\n const bd = $('linkPluginDocs');\n if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} });\n const bi = $('btnPluginInstall');\n if (bi) bi.addEventListener('click', async () => {\n try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); }\n catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); }\n await loadPlugins();\n });\n const bo = $('btnPluginOpenDir');\n if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} });\n const bg = $('btnPluginGit');\n if (bg) bg.addEventListener('click', openPluginGitModal);\n const bgc = $('btnGitCancel');\n if (bgc) bgc.addEventListener('click', closePluginGitModal);\n const bgm = $('pluginGitModal');\n if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); });\n const bgo = $('btnPluginGitGo');\n if (bgo) bgo.addEventListener('click', importFromGit);\n const gu = $('pluginGitUrl');\n if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); });\n }\n let plugins = [];\n try { plugins = await api.pluginList(); }\n catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); }\n const arr = Array.isArray(plugins) ? plugins : [];\n renderPlugins(arr);\n for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); }\n}\nasync function checkPluginUpdate(id) {\n let r;\n try { r = await api.pluginCheckUpdate(id); } catch (_) { return; }\n if (!r || !r.updateAvailable) return;\n const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id;\n const slot = document.querySelector('[data-update-slot=\"' + sel + '\"]');\n if (slot) slot.innerHTML = '<button class=\"text-[10.5px] font-semibold text-amber bg-amber-soft rounded-full px-1.75 py-0.25 border-none cursor-pointer hover:opacity-85\" data-plugin-update=\"' + escapeHtml(id) + '\" title=\"' + escapeHtml(I18n.t('plugins.updateTitle', { ver: r.latest || '' })) + '\">↑ v' + escapeHtml(r.latest || '') + '</button>';\n}\nasync function importFromGit() {\n const u = $('pluginGitUrl');\n const url = ((u && u.value) || '').trim();\n if (!url) return;\n closePluginGitModal();\n showPluginBusy(I18n.t('plugins.importing'));\n try {\n const r = await api.pluginInstallGit(url);\n hidePluginBusy();\n if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok');\n } catch (e) {\n hidePluginBusy();\n showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err');\n }\n await loadPlugins();\n}\nfunction renderPlugins(plugins) {\n const list = $('pluginList');\n if (!list) return;\n const empty = $('emptyPlugins');\n if (empty) empty.classList.toggle('hidden', plugins.length > 0);\n list.innerHTML = '';\n for (const p of plugins) {\n const running = !!p.running;\n const auth = p.auth || {};\n const st = auth.state || '';\n const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : ''))\n : st === 'expired' ? I18n.t('plugins.authExpired')\n : st === 'logged_out' ? I18n.t('plugins.authLoggedOut')\n : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning');\n const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption');\n const iconData = renderProviderIcon(p.name, p.icon);\n const dot = `<span class=\"inline-block w-1.5 h-1.5 rounded-full ${running ? 'bg-green' : 'bg-border-strong'} shrink-0\"></span>`;\n const toggleBtn = `<button class=\"btn btn-sm ${running ? 'bg-red-soft text-red border border-red/18' : 'bg-green-soft text-green border border-green/18'} rounded-md px-2.75 py-1.25 font-semibold text-[11px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\" data-plugin-toggle=\"${escapeHtml(p.id)}\" data-enabled=\"${running ? '1' : '0'}\">${running ? escapeHtml(I18n.t('plugins.disable')) : escapeHtml(I18n.t('plugins.enable'))}</button>`;\n const delBtn = `<button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red\" title=\"${escapeHtml(I18n.t('plugins.deleteTitle'))}\" data-plugin-uninstall=\"${escapeHtml(p.id)}\" data-plugin-name=\"${escapeHtml(p.name || p.id)}\">${I.trash || '⌫'}</button>`;\n // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest.\n const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => {\n if (!a || !a.id) return '';\n // Links open in a browser and never touch the plugin, so they don't need it\n // running; form/call actions default to requiring a running plugin.\n const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false);\n const disabled = needsRun && !running;\n const label = escapeHtml(a.label || a.id);\n return `<button class=\"btn btn-sm bg-bg-elev text-fg border border-border-custom rounded-md px-2.5 py-1.25 font-medium text-[11px] leading-none cursor-pointer hover:bg-chip-bg hover:border-border-strong active:scale-[0.985] disabled:opacity-45 disabled:cursor-not-allowed\" data-plugin-actionbtn=\"${escapeHtml(p.id)}\" data-action-id=\"${escapeHtml(a.id)}\" data-action-kind=\"${escapeHtml(a.kind || 'call')}\"${a.url ? ` data-action-url=\"${escapeHtml(a.url)}\"` : ''}${disabled ? ' disabled' : ''}>${label}</button>`;\n }).join('');\n pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : [];\n const el = document.createElement('div');\n el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong';\n el.dataset.id = p.id;\n el.innerHTML = `\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name || p.id)} <span class=\"text-[10.5px] font-mono text-caption font-normal\">v${escapeHtml(p.version || '')}</span>${p.official ? ' <span class=\"text-[10px] font-semibold text-brand bg-brand-soft rounded-full px-1.5 py-0.25 shrink-0\" title=\"' + escapeHtml(I18n.t('plugins.trusted')) + '\">' + escapeHtml(I18n.t('plugins.trusted')) + '</span>' : ''}<span data-update-slot=\"${escapeHtml(p.id)}\"></span> <span class=\"proto-badge proto-badge-xlate\">${escapeHtml(p.protocol || '')}</span></div>\n <div class=\"mt-0.5 text-xs text-caption truncate\">${escapeHtml(p.description || '')}</div>\n <div class=\"mt-1 flex items-center gap-1.5 text-[11.5px] ${authColor}\" data-plugin-status=\"${escapeHtml(p.id)}\">${dot}<span>${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}</span></div>\n </div>\n <div class=\"flex items-center gap-1.5 shrink-0\">${actionBtns}${toggleBtn}${delBtn}</div>`;\n list.appendChild(el);\n }\n}\nasync function onPluginAction(e) {\n const actionBtn = e.target.closest('[data-plugin-actionbtn]');\n if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; }\n const toggle = e.target.closest('[data-plugin-toggle]');\n const uninstall = e.target.closest('[data-plugin-uninstall]');\n const update = e.target.closest('[data-plugin-update]');\n const btn = toggle || uninstall || update;\n if (!btn) return;\n btn.disabled = true;\n try {\n if (toggle) {\n const enabling = toggle.dataset.enabled !== '1';\n // Starting a sidecar spawns a process and health-gates it (can take a\n // beat), so give the button an immediate spinner + \"Starting…\".\n if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting'));\n markPluginCardBusy(toggle);\n await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling);\n config = await api.getConfig(); // enabling adds a provider, disabling removes it\n renderProviders();\n } else if (uninstall) {\n const ok = await confirmDialog({\n title: I18n.t('plugins.deleteTitle'),\n message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }),\n confirmText: I18n.t('plugins.confirmDelete'),\n danger: true,\n });\n if (!ok) { uninstall.disabled = false; return; }\n const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall);\n if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); }\n } else if (update) {\n update.disabled = true;\n await api.pluginUpdate(update.dataset.pluginUpdate);\n config = await api.getConfig(); renderProviders();\n }\n } catch (err) {\n showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err');\n }\n await loadPlugins();\n}\nfunction openPluginGitModal() {\n const m = $('pluginGitModal'); if (!m) return;\n m.classList.remove('hidden');\n const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); }\n}\nfunction closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); }\n// Full-screen blocking overlay shown while a git clone/build runs — the user\n// cannot interact with the rest of the app until it finishes.\nfunction showPluginBusy(text) {\n let ov = document.getElementById('pluginBusy');\n if (!ov) {\n ov = document.createElement('div');\n ov.id = 'pluginBusy';\n ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md';\n ov.style.background = 'rgba(0,0,0,0.45)';\n ov.innerHTML = '<div class=\"animate-spin\" style=\"width:34px;height:34px;border:3px solid rgba(255,255,255,0.28);border-top-color:#fff;border-radius:50%\"></div><p id=\"pluginBusyText\" style=\"color:#fff;margin-top:14px;font-size:13px;font-weight:600\"></p>';\n document.body.appendChild(ov);\n }\n const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || '';\n ov.style.display = 'flex';\n}\nfunction hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; }\n\n// Small inline spinner used for per-button loading (enable/start).\nfunction pluginSpinner() {\n return '<span class=\"animate-spin inline-block w-3 h-3 rounded-full border-[1.5px] border-current border-t-transparent align-[-2px]\"></span>';\n}\n// Turn a button into a busy state: spinner + text, disabled. Reset happens on the\n// next renderPlugins() (loadPlugins re-renders the whole list from fresh status).\nfunction setPluginBtnBusy(btn, text) {\n if (!btn) return;\n btn.disabled = true;\n btn.innerHTML = `<span class=\"inline-flex items-center gap-1.25\">${pluginSpinner()}${escapeHtml(text || '')}</span>`;\n}\n// Dim the card and swap its status line to a \"starting\" spinner while the sidecar\n// spins up, so the whole row reads as in-progress (not just the button).\nfunction markPluginCardBusy(btn) {\n const card = btn && btn.closest('.plugin');\n if (!card) return;\n card.classList.add('opacity-60', 'pointer-events-none');\n const line = card.querySelector('[data-plugin-status]');\n if (line) line.innerHTML = `${pluginSpinner()}<span>${escapeHtml(I18n.t('plugins.starting'))}</span>`;\n}\n\n// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ----\nasync function runPluginDeclaredAction(btn) {\n if (btn.disabled) return;\n const pid = btn.dataset.pluginActionbtn;\n const actionId = btn.dataset.actionId;\n const kind = btn.dataset.actionKind || 'call';\n if (kind === 'link') {\n const url = btn.dataset.actionUrl;\n if (url) { try { api.openExternal(url); } catch (_) {} }\n return;\n }\n const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId };\n if (kind === 'form') { await openPluginActionForm(pid, action); return; }\n // kind === 'call': fire-and-report, with an optional confirm gate\n // (confirmDialog, not window.confirm — the Tauri webview never shows the latter)\n if (action.confirm) {\n const ok = await confirmDialog({\n title: action.label || action.id,\n message: action.confirm,\n confirmText: action.label || action.id,\n });\n if (!ok) return;\n }\n btn.disabled = true;\n try {\n const r = await api.pluginAction(pid, actionId, {});\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n } catch (err) {\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n btn.disabled = false;\n await loadPlugins();\n}\n\n// Build one form control from a field spec. Recognized types: text (default),\n// number, password, textarea, select, checkbox.\nfunction pluginFieldControl(f, val) {\n const key = escapeHtml(f.key);\n const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary';\n const v = (val != null ? val : (f.default != null ? f.default : ''));\n if (f.type === 'select') {\n const opts = (Array.isArray(f.options) ? f.options : []).map((o) => {\n const ov = (o && typeof o === 'object') ? o.value : o;\n const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;\n const sel = String(ov) === String(v) ? ' selected' : '';\n return `<option value=\"${escapeHtml(String(ov))}\"${sel}>${escapeHtml(String(ol))}</option>`;\n }).join('');\n return `<select data-field-key=\"${key}\" class=\"${cls}\">${opts}</select>`;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return `<input type=\"checkbox\" data-field-key=\"${key}\" ${on ? 'checked' : ''} class=\"w-4 h-4 accent-brand self-start\" />`;\n }\n if (f.type === 'textarea') {\n return `<textarea data-field-key=\"${key}\" rows=\"3\" class=\"${cls}\" placeholder=\"${escapeHtml(f.placeholder || '')}\">${escapeHtml(String(v))}</textarea>`;\n }\n const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';\n const mono = type === 'text' || type === 'number' ? ' font-mono' : '';\n const minmax = f.type === 'number'\n ? `${f.min != null ? ` min=\"${escapeHtml(String(f.min))}\"` : ''}${f.max != null ? ` max=\"${escapeHtml(String(f.max))}\"` : ''}`\n : '';\n return `<input type=\"${type}\" data-field-key=\"${key}\" value=\"${escapeHtml(String(v))}\" placeholder=\"${escapeHtml(f.placeholder || '')}\"${minmax} class=\"${cls}${mono}\" />`;\n}\n\n// Read the form back into a values object, coercing/validating by field type.\n// Returns null (and focuses the offending control) if a required/number check fails.\nfunction collectPluginFormValues(root, fields) {\n const out = {};\n for (const f of fields) {\n const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key;\n const el = root.querySelector(`[data-field-key=\"${sel}\"]`);\n if (!el) continue;\n let v = f.type === 'checkbox' ? !!el.checked : el.value;\n if (f.type === 'number') {\n if (v === '' || v == null) {\n v = null;\n } else {\n const n = Number(v);\n if (Number.isNaN(n)) { el.focus(); return null; }\n v = n;\n }\n }\n if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; }\n out[f.key] = v;\n }\n return out;\n}\n\n// Open a modal built from a plugin action's field specs; prefill from the plugin,\n// then POST the collected values back through the host.\nasync function openPluginActionForm(pid, action) {\n const fields = Array.isArray(action.fields) ? action.fields : [];\n let values = {};\n if (action.loadOnOpen !== false) {\n try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; }\n catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); }\n }\n const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove();\n const ov = document.createElement('div');\n ov.id = 'pluginActionModal';\n ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md';\n const rows = fields.map((f) => `\n <label class=\"field flex flex-col gap-1.25\">\n <span class=\"field-label text-[11px] font-semibold text-caption uppercase tracking-[0.03em]\">${escapeHtml(f.label || f.key)}</span>\n ${pluginFieldControl(f, values[f.key])}\n ${f.help ? `<span class=\"text-[11px] text-caption leading-[1.4]\">${escapeHtml(f.help)}</span>` : ''}\n </label>`).join('');\n ov.innerHTML = `\n <div class=\"sheet w-[460px] max-w-[92vw] bg-bg-elev backdrop-blur-[40px] border border-window-border rounded-2xl shadow-[0_24px_64px_rgba(0,0,0,0.18)] flex flex-col\">\n <header class=\"flex items-center gap-2 p-[14px_18px] border-b border-border-custom\">\n <h3 class=\"text-[14px] font-semibold tracking-tight\">${escapeHtml(action.label || action.id)}</h3>\n </header>\n <div class=\"p-[18px_20px] flex flex-col gap-3\">\n ${rows}\n <div class=\"flex justify-end gap-2 mt-1\">\n <button data-act=\"cancel\" class=\"btn bg-bg-elev text-muted border border-border-custom rounded-md px-3 py-2 font-medium text-[12px] leading-none cursor-pointer hover:bg-chip-bg hover:text-fg\">${escapeHtml(I18n.t('plugins.cancel'))}</button>\n <button data-act=\"submit\" class=\"btn bg-brand text-white border-none rounded-md px-3.5 py-2 font-semibold text-[12px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\">${escapeHtml(action.submitLabel || I18n.t('plugins.save'))}</button>\n </div>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const close = () => ov.remove();\n ov.addEventListener('click', (e) => { if (e.target === ov) close(); });\n ov.querySelector('[data-act=\"cancel\"]').addEventListener('click', close);\n ov.querySelector('[data-act=\"submit\"]').addEventListener('click', async () => {\n const vals = collectPluginFormValues(ov, fields);\n if (vals == null) return;\n const submitBtn = ov.querySelector('[data-act=\"submit\"]');\n submitBtn.disabled = true;\n try {\n const r = await api.pluginAction(pid, action.id, vals);\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n close();\n await loadPlugins();\n } catch (err) {\n submitBtn.disabled = false;\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n });\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n plugins: 'view-plugins',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'plugins') loadPlugins();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) {\n // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS).\n const ok = await confirmDialog({\n title: I18n.t('providers.delete'),\n message: I18n.t('providers.confirmDelete'),\n confirmText: I18n.t('providers.delete'),\n danger: true,\n });\n if (ok) { config = await api.deleteProvider(btn.dataset.del); renderAll(); }\n return;\n }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.running) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n try {\n config = await api.setActive(card.dataset.id);\n } catch (err) {\n const msg = (err && err.message) || String(err);\n showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err');\n return;\n }\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const testedProvider = collectProvider();\n const res = await api.testProvider(testedProvider);\n if (res.ok && res.baseUrl && $('fBaseUrl').value.trim() === testedProvider.baseUrl) {\n $('fBaseUrl').value = res.baseUrl;\n }\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n if (api.onConfigChanged) api.onConfigChanged(async (next) => {\n const previous = config;\n const previousProvider = editingId && previous.providers.find((p) => p.id === editingId);\n const baseUrlInput = editingId ? $('fBaseUrl') : null;\n const baseUrlWasUnchanged = !!(previousProvider && baseUrlInput && baseUrlInput.value === (previousProvider.baseUrl || ''));\n config = next && Array.isArray(next.providers) ? next : await api.getConfig();\n if (baseUrlWasUnchanged) {\n const updatedProvider = config.providers.find((p) => p.id === editingId);\n if (updatedProvider) baseUrlInput.value = updatedProvider.baseUrl || '';\n }\n renderAll();\n });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "fc9009ee227eb66fc2f73fd5e82210a2a66482ab": {"t": 1785766814.0894191, "n": 104685, "h": "6eff6852a0372a7e", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n google: { name: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-3.5-flash', smallFastModel: 'gemini-3.1-flash-lite', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', google: 'Google AI Studio', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { google: ['google ai studio', 'gemini', 'generativelanguage'], kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n const asset = file === 'google' ? 'google-ai-studio.png' : `${file}.svg`;\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${asset}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\n/* ---------- plugins (sidecar coding-agent backends) ---------- */\nconst PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md';\nconst pluginActionsById = {}; // pluginId -> declared actions (for the form modal)\nlet pluginListWired = false;\nasync function loadPlugins() {\n const list = $('pluginList');\n if (!list) return;\n if (!pluginListWired) {\n pluginListWired = true;\n list.addEventListener('click', onPluginAction);\n const bd = $('linkPluginDocs');\n if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} });\n const bi = $('btnPluginInstall');\n if (bi) bi.addEventListener('click', async () => {\n try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); }\n catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); }\n await loadPlugins();\n });\n const bo = $('btnPluginOpenDir');\n if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} });\n const bg = $('btnPluginGit');\n if (bg) bg.addEventListener('click', openPluginGitModal);\n const bgc = $('btnGitCancel');\n if (bgc) bgc.addEventListener('click', closePluginGitModal);\n const bgm = $('pluginGitModal');\n if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); });\n const bgo = $('btnPluginGitGo');\n if (bgo) bgo.addEventListener('click', importFromGit);\n const gu = $('pluginGitUrl');\n if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); });\n }\n let plugins = [];\n try { plugins = await api.pluginList(); }\n catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); }\n const arr = Array.isArray(plugins) ? plugins : [];\n renderPlugins(arr);\n for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); }\n}\nasync function checkPluginUpdate(id) {\n let r;\n try { r = await api.pluginCheckUpdate(id); } catch (_) { return; }\n if (!r || !r.updateAvailable) return;\n const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id;\n const slot = document.querySelector('[data-update-slot=\"' + sel + '\"]');\n if (slot) slot.innerHTML = '<button class=\"text-[10.5px] font-semibold text-amber bg-amber-soft rounded-full px-1.75 py-0.25 border-none cursor-pointer hover:opacity-85\" data-plugin-update=\"' + escapeHtml(id) + '\" title=\"' + escapeHtml(I18n.t('plugins.updateTitle', { ver: r.latest || '' })) + '\">↑ v' + escapeHtml(r.latest || '') + '</button>';\n}\nasync function importFromGit() {\n const u = $('pluginGitUrl');\n const url = ((u && u.value) || '').trim();\n if (!url) return;\n closePluginGitModal();\n showPluginBusy(I18n.t('plugins.importing'));\n try {\n const r = await api.pluginInstallGit(url);\n hidePluginBusy();\n if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok');\n } catch (e) {\n hidePluginBusy();\n showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err');\n }\n await loadPlugins();\n}\nfunction renderPlugins(plugins) {\n const list = $('pluginList');\n if (!list) return;\n const empty = $('emptyPlugins');\n if (empty) empty.classList.toggle('hidden', plugins.length > 0);\n list.innerHTML = '';\n for (const p of plugins) {\n const running = !!p.running;\n const auth = p.auth || {};\n const st = auth.state || '';\n const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : ''))\n : st === 'expired' ? I18n.t('plugins.authExpired')\n : st === 'logged_out' ? I18n.t('plugins.authLoggedOut')\n : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning');\n const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption');\n const iconData = renderProviderIcon(p.name, p.icon);\n const dot = `<span class=\"inline-block w-1.5 h-1.5 rounded-full ${running ? 'bg-green' : 'bg-border-strong'} shrink-0\"></span>`;\n const toggleBtn = `<button class=\"btn btn-sm ${running ? 'bg-red-soft text-red border border-red/18' : 'bg-green-soft text-green border border-green/18'} rounded-md px-2.75 py-1.25 font-semibold text-[11px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\" data-plugin-toggle=\"${escapeHtml(p.id)}\" data-enabled=\"${running ? '1' : '0'}\">${running ? escapeHtml(I18n.t('plugins.disable')) : escapeHtml(I18n.t('plugins.enable'))}</button>`;\n const delBtn = `<button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red\" title=\"${escapeHtml(I18n.t('plugins.deleteTitle'))}\" data-plugin-uninstall=\"${escapeHtml(p.id)}\" data-plugin-name=\"${escapeHtml(p.name || p.id)}\">${I.trash || '⌫'}</button>`;\n // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest.\n const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => {\n if (!a || !a.id) return '';\n // Links open in a browser and never touch the plugin, so they don't need it\n // running; form/call actions default to requiring a running plugin.\n const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false);\n const disabled = needsRun && !running;\n const label = escapeHtml(a.label || a.id);\n return `<button class=\"btn btn-sm bg-bg-elev text-fg border border-border-custom rounded-md px-2.5 py-1.25 font-medium text-[11px] leading-none cursor-pointer hover:bg-chip-bg hover:border-border-strong active:scale-[0.985] disabled:opacity-45 disabled:cursor-not-allowed\" data-plugin-actionbtn=\"${escapeHtml(p.id)}\" data-action-id=\"${escapeHtml(a.id)}\" data-action-kind=\"${escapeHtml(a.kind || 'call')}\"${a.url ? ` data-action-url=\"${escapeHtml(a.url)}\"` : ''}${disabled ? ' disabled' : ''}>${label}</button>`;\n }).join('');\n pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : [];\n const el = document.createElement('div');\n el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong';\n el.dataset.id = p.id;\n el.innerHTML = `\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name || p.id)} <span class=\"text-[10.5px] font-mono text-caption font-normal\">v${escapeHtml(p.version || '')}</span>${p.official ? ' <span class=\"text-[10px] font-semibold text-brand bg-brand-soft rounded-full px-1.5 py-0.25 shrink-0\" title=\"' + escapeHtml(I18n.t('plugins.trusted')) + '\">' + escapeHtml(I18n.t('plugins.trusted')) + '</span>' : ''}<span data-update-slot=\"${escapeHtml(p.id)}\"></span> <span class=\"proto-badge proto-badge-xlate\">${escapeHtml(p.protocol || '')}</span></div>\n <div class=\"mt-0.5 text-xs text-caption truncate\">${escapeHtml(p.description || '')}</div>\n <div class=\"mt-1 flex items-center gap-1.5 text-[11.5px] ${authColor}\" data-plugin-status=\"${escapeHtml(p.id)}\">${dot}<span>${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}</span></div>\n </div>\n <div class=\"flex items-center gap-1.5 shrink-0\">${actionBtns}${toggleBtn}${delBtn}</div>`;\n list.appendChild(el);\n }\n}\nasync function onPluginAction(e) {\n const actionBtn = e.target.closest('[data-plugin-actionbtn]');\n if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; }\n const toggle = e.target.closest('[data-plugin-toggle]');\n const uninstall = e.target.closest('[data-plugin-uninstall]');\n const update = e.target.closest('[data-plugin-update]');\n const btn = toggle || uninstall || update;\n if (!btn) return;\n btn.disabled = true;\n try {\n if (toggle) {\n const enabling = toggle.dataset.enabled !== '1';\n // Starting a sidecar spawns a process and health-gates it (can take a\n // beat), so give the button an immediate spinner + \"Starting…\".\n if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting'));\n markPluginCardBusy(toggle);\n await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling);\n config = await api.getConfig(); // enabling adds a provider, disabling removes it\n renderProviders();\n } else if (uninstall) {\n const ok = await confirmDialog({\n title: I18n.t('plugins.deleteTitle'),\n message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }),\n confirmText: I18n.t('plugins.confirmDelete'),\n danger: true,\n });\n if (!ok) { uninstall.disabled = false; return; }\n const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall);\n if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); }\n } else if (update) {\n update.disabled = true;\n await api.pluginUpdate(update.dataset.pluginUpdate);\n config = await api.getConfig(); renderProviders();\n }\n } catch (err) {\n showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err');\n }\n await loadPlugins();\n}\nfunction openPluginGitModal() {\n const m = $('pluginGitModal'); if (!m) return;\n m.classList.remove('hidden');\n const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); }\n}\nfunction closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); }\n// Full-screen blocking overlay shown while a git clone/build runs — the user\n// cannot interact with the rest of the app until it finishes.\nfunction showPluginBusy(text) {\n let ov = document.getElementById('pluginBusy');\n if (!ov) {\n ov = document.createElement('div');\n ov.id = 'pluginBusy';\n ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md';\n ov.style.background = 'rgba(0,0,0,0.45)';\n ov.innerHTML = '<div class=\"animate-spin\" style=\"width:34px;height:34px;border:3px solid rgba(255,255,255,0.28);border-top-color:#fff;border-radius:50%\"></div><p id=\"pluginBusyText\" style=\"color:#fff;margin-top:14px;font-size:13px;font-weight:600\"></p>';\n document.body.appendChild(ov);\n }\n const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || '';\n ov.style.display = 'flex';\n}\nfunction hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; }\n\n// Small inline spinner used for per-button loading (enable/start).\nfunction pluginSpinner() {\n return '<span class=\"animate-spin inline-block w-3 h-3 rounded-full border-[1.5px] border-current border-t-transparent align-[-2px]\"></span>';\n}\n// Turn a button into a busy state: spinner + text, disabled. Reset happens on the\n// next renderPlugins() (loadPlugins re-renders the whole list from fresh status).\nfunction setPluginBtnBusy(btn, text) {\n if (!btn) return;\n btn.disabled = true;\n btn.innerHTML = `<span class=\"inline-flex items-center gap-1.25\">${pluginSpinner()}${escapeHtml(text || '')}</span>`;\n}\n// Dim the card and swap its status line to a \"starting\" spinner while the sidecar\n// spins up, so the whole row reads as in-progress (not just the button).\nfunction markPluginCardBusy(btn) {\n const card = btn && btn.closest('.plugin');\n if (!card) return;\n card.classList.add('opacity-60', 'pointer-events-none');\n const line = card.querySelector('[data-plugin-status]');\n if (line) line.innerHTML = `${pluginSpinner()}<span>${escapeHtml(I18n.t('plugins.starting'))}</span>`;\n}\n\n// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ----\nasync function runPluginDeclaredAction(btn) {\n if (btn.disabled) return;\n const pid = btn.dataset.pluginActionbtn;\n const actionId = btn.dataset.actionId;\n const kind = btn.dataset.actionKind || 'call';\n if (kind === 'link') {\n const url = btn.dataset.actionUrl;\n if (url) { try { api.openExternal(url); } catch (_) {} }\n return;\n }\n const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId };\n if (kind === 'form') { await openPluginActionForm(pid, action); return; }\n // kind === 'call': fire-and-report, with an optional confirm gate\n // (confirmDialog, not window.confirm — the Tauri webview never shows the latter)\n if (action.confirm) {\n const ok = await confirmDialog({\n title: action.label || action.id,\n message: action.confirm,\n confirmText: action.label || action.id,\n });\n if (!ok) return;\n }\n btn.disabled = true;\n try {\n const r = await api.pluginAction(pid, actionId, {});\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n } catch (err) {\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n btn.disabled = false;\n await loadPlugins();\n}\n\n// Build one form control from a field spec. Recognized types: text (default),\n// number, password, textarea, select, checkbox.\nfunction pluginFieldControl(f, val) {\n const key = escapeHtml(f.key);\n const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary';\n const v = (val != null ? val : (f.default != null ? f.default : ''));\n if (f.type === 'select') {\n const opts = (Array.isArray(f.options) ? f.options : []).map((o) => {\n const ov = (o && typeof o === 'object') ? o.value : o;\n const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;\n const sel = String(ov) === String(v) ? ' selected' : '';\n return `<option value=\"${escapeHtml(String(ov))}\"${sel}>${escapeHtml(String(ol))}</option>`;\n }).join('');\n return `<select data-field-key=\"${key}\" class=\"${cls}\">${opts}</select>`;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return `<input type=\"checkbox\" data-field-key=\"${key}\" ${on ? 'checked' : ''} class=\"w-4 h-4 accent-brand self-start\" />`;\n }\n if (f.type === 'textarea') {\n return `<textarea data-field-key=\"${key}\" rows=\"3\" class=\"${cls}\" placeholder=\"${escapeHtml(f.placeholder || '')}\">${escapeHtml(String(v))}</textarea>`;\n }\n const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';\n const mono = type === 'text' || type === 'number' ? ' font-mono' : '';\n const minmax = f.type === 'number'\n ? `${f.min != null ? ` min=\"${escapeHtml(String(f.min))}\"` : ''}${f.max != null ? ` max=\"${escapeHtml(String(f.max))}\"` : ''}`\n : '';\n return `<input type=\"${type}\" data-field-key=\"${key}\" value=\"${escapeHtml(String(v))}\" placeholder=\"${escapeHtml(f.placeholder || '')}\"${minmax} class=\"${cls}${mono}\" />`;\n}\n\n// Read the form back into a values object, coercing/validating by field type.\n// Returns null (and focuses the offending control) if a required/number check fails.\nfunction collectPluginFormValues(root, fields) {\n const out = {};\n for (const f of fields) {\n const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key;\n const el = root.querySelector(`[data-field-key=\"${sel}\"]`);\n if (!el) continue;\n let v = f.type === 'checkbox' ? !!el.checked : el.value;\n if (f.type === 'number') {\n if (v === '' || v == null) {\n v = null;\n } else {\n const n = Number(v);\n if (Number.isNaN(n)) { el.focus(); return null; }\n v = n;\n }\n }\n if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; }\n out[f.key] = v;\n }\n return out;\n}\n\n// Open a modal built from a plugin action's field specs; prefill from the plugin,\n// then POST the collected values back through the host.\nasync function openPluginActionForm(pid, action) {\n const fields = Array.isArray(action.fields) ? action.fields : [];\n let values = {};\n if (action.loadOnOpen !== false) {\n try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; }\n catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); }\n }\n const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove();\n const ov = document.createElement('div');\n ov.id = 'pluginActionModal';\n ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md';\n const rows = fields.map((f) => `\n <label class=\"field flex flex-col gap-1.25\">\n <span class=\"field-label text-[11px] font-semibold text-caption uppercase tracking-[0.03em]\">${escapeHtml(f.label || f.key)}</span>\n ${pluginFieldControl(f, values[f.key])}\n ${f.help ? `<span class=\"text-[11px] text-caption leading-[1.4]\">${escapeHtml(f.help)}</span>` : ''}\n </label>`).join('');\n ov.innerHTML = `\n <div class=\"sheet w-[460px] max-w-[92vw] bg-bg-elev backdrop-blur-[40px] border border-window-border rounded-2xl shadow-[0_24px_64px_rgba(0,0,0,0.18)] flex flex-col\">\n <header class=\"flex items-center gap-2 p-[14px_18px] border-b border-border-custom\">\n <h3 class=\"text-[14px] font-semibold tracking-tight\">${escapeHtml(action.label || action.id)}</h3>\n </header>\n <div class=\"p-[18px_20px] flex flex-col gap-3\">\n ${rows}\n <div class=\"flex justify-end gap-2 mt-1\">\n <button data-act=\"cancel\" class=\"btn bg-bg-elev text-muted border border-border-custom rounded-md px-3 py-2 font-medium text-[12px] leading-none cursor-pointer hover:bg-chip-bg hover:text-fg\">${escapeHtml(I18n.t('plugins.cancel'))}</button>\n <button data-act=\"submit\" class=\"btn bg-brand text-white border-none rounded-md px-3.5 py-2 font-semibold text-[12px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\">${escapeHtml(action.submitLabel || I18n.t('plugins.save'))}</button>\n </div>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const close = () => ov.remove();\n ov.addEventListener('click', (e) => { if (e.target === ov) close(); });\n ov.querySelector('[data-act=\"cancel\"]').addEventListener('click', close);\n ov.querySelector('[data-act=\"submit\"]').addEventListener('click', async () => {\n const vals = collectPluginFormValues(ov, fields);\n if (vals == null) return;\n const submitBtn = ov.querySelector('[data-act=\"submit\"]');\n submitBtn.disabled = true;\n try {\n const r = await api.pluginAction(pid, action.id, vals);\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n close();\n await loadPlugins();\n } catch (err) {\n submitBtn.disabled = false;\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n });\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n plugins: 'view-plugins',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'plugins') loadPlugins();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) {\n // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS).\n const ok = await confirmDialog({\n title: I18n.t('providers.delete'),\n message: I18n.t('providers.confirmDelete'),\n confirmText: I18n.t('providers.delete'),\n danger: true,\n });\n if (ok) { config = await api.deleteProvider(btn.dataset.del); renderAll(); }\n return;\n }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.running) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n try {\n config = await api.setActive(card.dataset.id);\n } catch (err) {\n const msg = (err && err.message) || String(err);\n showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err');\n return;\n }\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const testedProvider = collectProvider();\n const res = await api.testProvider(testedProvider);\n if (res.ok && res.baseUrl && $('fBaseUrl').value.trim() === testedProvider.baseUrl) {\n $('fBaseUrl').value = res.baseUrl;\n }\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n if (api.onConfigChanged) api.onConfigChanged(async (next) => {\n const previous = config;\n const previousProvider = editingId && previous.providers.find((p) => p.id === editingId);\n const baseUrlInput = editingId ? $('fBaseUrl') : null;\n const baseUrlWasUnchanged = !!(previousProvider && baseUrlInput && baseUrlInput.value === (previousProvider.baseUrl || ''));\n config = next && Array.isArray(next.providers) ? next : await api.getConfig();\n if (baseUrlWasUnchanged) {\n const updatedProvider = config.providers.find((p) => p.id === editingId);\n if (updatedProvider) baseUrlInput.value = updatedProvider.baseUrl || '';\n }\n renderAll();\n });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "29a1e2cc31d20e732114adc97db4877d97688a8e": {"t": 1785766816.473501, "n": 103454, "h": "23fa84b680af12f3", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\n/* ---------- plugins (sidecar coding-agent backends) ---------- */\nconst PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md';\nconst pluginActionsById = {}; // pluginId -> declared actions (for the form modal)\nlet pluginListWired = false;\nasync function loadPlugins() {\n const list = $('pluginList');\n if (!list) return;\n if (!pluginListWired) {\n pluginListWired = true;\n list.addEventListener('click', onPluginAction);\n const bd = $('linkPluginDocs');\n if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} });\n const bi = $('btnPluginInstall');\n if (bi) bi.addEventListener('click', async () => {\n try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); }\n catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); }\n await loadPlugins();\n });\n const bo = $('btnPluginOpenDir');\n if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} });\n const bg = $('btnPluginGit');\n if (bg) bg.addEventListener('click', openPluginGitModal);\n const bgc = $('btnGitCancel');\n if (bgc) bgc.addEventListener('click', closePluginGitModal);\n const bgm = $('pluginGitModal');\n if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); });\n const bgo = $('btnPluginGitGo');\n if (bgo) bgo.addEventListener('click', importFromGit);\n const gu = $('pluginGitUrl');\n if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); });\n }\n let plugins = [];\n try { plugins = await api.pluginList(); }\n catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); }\n const arr = Array.isArray(plugins) ? plugins : [];\n renderPlugins(arr);\n for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); }\n}\nasync function checkPluginUpdate(id) {\n let r;\n try { r = await api.pluginCheckUpdate(id); } catch (_) { return; }\n if (!r || !r.updateAvailable) return;\n const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id;\n const slot = document.querySelector('[data-update-slot=\"' + sel + '\"]');\n if (slot) slot.innerHTML = '<button class=\"text-[10.5px] font-semibold text-amber bg-amber-soft rounded-full px-1.75 py-0.25 border-none cursor-pointer hover:opacity-85\" data-plugin-update=\"' + escapeHtml(id) + '\" title=\"' + escapeHtml(I18n.t('plugins.updateTitle', { ver: r.latest || '' })) + '\">↑ v' + escapeHtml(r.latest || '') + '</button>';\n}\nasync function importFromGit() {\n const u = $('pluginGitUrl');\n const url = ((u && u.value) || '').trim();\n if (!url) return;\n closePluginGitModal();\n showPluginBusy(I18n.t('plugins.importing'));\n try {\n const r = await api.pluginInstallGit(url);\n hidePluginBusy();\n if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok');\n } catch (e) {\n hidePluginBusy();\n showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err');\n }\n await loadPlugins();\n}\nfunction renderPlugins(plugins) {\n const list = $('pluginList');\n if (!list) return;\n const empty = $('emptyPlugins');\n if (empty) empty.classList.toggle('hidden', plugins.length > 0);\n list.innerHTML = '';\n for (const p of plugins) {\n const running = !!p.running;\n const auth = p.auth || {};\n const st = auth.state || '';\n const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : ''))\n : st === 'expired' ? I18n.t('plugins.authExpired')\n : st === 'logged_out' ? I18n.t('plugins.authLoggedOut')\n : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning');\n const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption');\n const iconData = renderProviderIcon(p.name, p.icon);\n const dot = `<span class=\"inline-block w-1.5 h-1.5 rounded-full ${running ? 'bg-green' : 'bg-border-strong'} shrink-0\"></span>`;\n const toggleBtn = `<button class=\"btn btn-sm ${running ? 'bg-red-soft text-red border border-red/18' : 'bg-green-soft text-green border border-green/18'} rounded-md px-2.75 py-1.25 font-semibold text-[11px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\" data-plugin-toggle=\"${escapeHtml(p.id)}\" data-enabled=\"${running ? '1' : '0'}\">${running ? escapeHtml(I18n.t('plugins.disable')) : escapeHtml(I18n.t('plugins.enable'))}</button>`;\n const delBtn = `<button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red\" title=\"${escapeHtml(I18n.t('plugins.deleteTitle'))}\" data-plugin-uninstall=\"${escapeHtml(p.id)}\" data-plugin-name=\"${escapeHtml(p.name || p.id)}\">${I.trash || '⌫'}</button>`;\n // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest.\n const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => {\n if (!a || !a.id) return '';\n // Links open in a browser and never touch the plugin, so they don't need it\n // running; form/call actions default to requiring a running plugin.\n const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false);\n const disabled = needsRun && !running;\n const label = escapeHtml(a.label || a.id);\n return `<button class=\"btn btn-sm bg-bg-elev text-fg border border-border-custom rounded-md px-2.5 py-1.25 font-medium text-[11px] leading-none cursor-pointer hover:bg-chip-bg hover:border-border-strong active:scale-[0.985] disabled:opacity-45 disabled:cursor-not-allowed\" data-plugin-actionbtn=\"${escapeHtml(p.id)}\" data-action-id=\"${escapeHtml(a.id)}\" data-action-kind=\"${escapeHtml(a.kind || 'call')}\"${a.url ? ` data-action-url=\"${escapeHtml(a.url)}\"` : ''}${disabled ? ' disabled' : ''}>${label}</button>`;\n }).join('');\n pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : [];\n const el = document.createElement('div');\n el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong';\n el.dataset.id = p.id;\n el.innerHTML = `\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name || p.id)} <span class=\"text-[10.5px] font-mono text-caption font-normal\">v${escapeHtml(p.version || '')}</span>${p.official ? ' <span class=\"text-[10px] font-semibold text-brand bg-brand-soft rounded-full px-1.5 py-0.25 shrink-0\" title=\"' + escapeHtml(I18n.t('plugins.trusted')) + '\">' + escapeHtml(I18n.t('plugins.trusted')) + '</span>' : ''}<span data-update-slot=\"${escapeHtml(p.id)}\"></span> <span class=\"proto-badge proto-badge-xlate\">${escapeHtml(p.protocol || '')}</span></div>\n <div class=\"mt-0.5 text-xs text-caption truncate\">${escapeHtml(p.description || '')}</div>\n <div class=\"mt-1 flex items-center gap-1.5 text-[11.5px] ${authColor}\" data-plugin-status=\"${escapeHtml(p.id)}\">${dot}<span>${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}</span></div>\n </div>\n <div class=\"flex items-center gap-1.5 shrink-0\">${actionBtns}${toggleBtn}${delBtn}</div>`;\n list.appendChild(el);\n }\n}\nasync function onPluginAction(e) {\n const actionBtn = e.target.closest('[data-plugin-actionbtn]');\n if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; }\n const toggle = e.target.closest('[data-plugin-toggle]');\n const uninstall = e.target.closest('[data-plugin-uninstall]');\n const update = e.target.closest('[data-plugin-update]');\n const btn = toggle || uninstall || update;\n if (!btn) return;\n btn.disabled = true;\n try {\n if (toggle) {\n const enabling = toggle.dataset.enabled !== '1';\n // Starting a sidecar spawns a process and health-gates it (can take a\n // beat), so give the button an immediate spinner + \"Starting…\".\n if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting'));\n markPluginCardBusy(toggle);\n await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling);\n config = await api.getConfig(); // enabling adds a provider, disabling removes it\n renderProviders();\n } else if (uninstall) {\n const ok = await confirmDialog({\n title: I18n.t('plugins.deleteTitle'),\n message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }),\n confirmText: I18n.t('plugins.confirmDelete'),\n danger: true,\n });\n if (!ok) { uninstall.disabled = false; return; }\n const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall);\n if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); }\n } else if (update) {\n update.disabled = true;\n await api.pluginUpdate(update.dataset.pluginUpdate);\n config = await api.getConfig(); renderProviders();\n }\n } catch (err) {\n showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err');\n }\n await loadPlugins();\n}\nfunction openPluginGitModal() {\n const m = $('pluginGitModal'); if (!m) return;\n m.classList.remove('hidden');\n const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); }\n}\nfunction closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); }\n// Full-screen blocking overlay shown while a git clone/build runs — the user\n// cannot interact with the rest of the app until it finishes.\nfunction showPluginBusy(text) {\n let ov = document.getElementById('pluginBusy');\n if (!ov) {\n ov = document.createElement('div');\n ov.id = 'pluginBusy';\n ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md';\n ov.style.background = 'rgba(0,0,0,0.45)';\n ov.innerHTML = '<div class=\"animate-spin\" style=\"width:34px;height:34px;border:3px solid rgba(255,255,255,0.28);border-top-color:#fff;border-radius:50%\"></div><p id=\"pluginBusyText\" style=\"color:#fff;margin-top:14px;font-size:13px;font-weight:600\"></p>';\n document.body.appendChild(ov);\n }\n const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || '';\n ov.style.display = 'flex';\n}\nfunction hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; }\n\n// Small inline spinner used for per-button loading (enable/start).\nfunction pluginSpinner() {\n return '<span class=\"animate-spin inline-block w-3 h-3 rounded-full border-[1.5px] border-current border-t-transparent align-[-2px]\"></span>';\n}\n// Turn a button into a busy state: spinner + text, disabled. Reset happens on the\n// next renderPlugins() (loadPlugins re-renders the whole list from fresh status).\nfunction setPluginBtnBusy(btn, text) {\n if (!btn) return;\n btn.disabled = true;\n btn.innerHTML = `<span class=\"inline-flex items-center gap-1.25\">${pluginSpinner()}${escapeHtml(text || '')}</span>`;\n}\n// Dim the card and swap its status line to a \"starting\" spinner while the sidecar\n// spins up, so the whole row reads as in-progress (not just the button).\nfunction markPluginCardBusy(btn) {\n const card = btn && btn.closest('.plugin');\n if (!card) return;\n card.classList.add('opacity-60', 'pointer-events-none');\n const line = card.querySelector('[data-plugin-status]');\n if (line) line.innerHTML = `${pluginSpinner()}<span>${escapeHtml(I18n.t('plugins.starting'))}</span>`;\n}\n\n// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ----\nasync function runPluginDeclaredAction(btn) {\n if (btn.disabled) return;\n const pid = btn.dataset.pluginActionbtn;\n const actionId = btn.dataset.actionId;\n const kind = btn.dataset.actionKind || 'call';\n if (kind === 'link') {\n const url = btn.dataset.actionUrl;\n if (url) { try { api.openExternal(url); } catch (_) {} }\n return;\n }\n const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId };\n if (kind === 'form') { await openPluginActionForm(pid, action); return; }\n // kind === 'call': fire-and-report, with an optional confirm gate\n // (confirmDialog, not window.confirm — the Tauri webview never shows the latter)\n if (action.confirm) {\n const ok = await confirmDialog({\n title: action.label || action.id,\n message: action.confirm,\n confirmText: action.label || action.id,\n });\n if (!ok) return;\n }\n btn.disabled = true;\n try {\n const r = await api.pluginAction(pid, actionId, {});\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n } catch (err) {\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n btn.disabled = false;\n await loadPlugins();\n}\n\n// Build one form control from a field spec. Recognized types: text (default),\n// number, password, textarea, select, checkbox.\nfunction pluginFieldControl(f, val) {\n const key = escapeHtml(f.key);\n const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary';\n const v = (val != null ? val : (f.default != null ? f.default : ''));\n if (f.type === 'select') {\n const opts = (Array.isArray(f.options) ? f.options : []).map((o) => {\n const ov = (o && typeof o === 'object') ? o.value : o;\n const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;\n const sel = String(ov) === String(v) ? ' selected' : '';\n return `<option value=\"${escapeHtml(String(ov))}\"${sel}>${escapeHtml(String(ol))}</option>`;\n }).join('');\n return `<select data-field-key=\"${key}\" class=\"${cls}\">${opts}</select>`;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return `<input type=\"checkbox\" data-field-key=\"${key}\" ${on ? 'checked' : ''} class=\"w-4 h-4 accent-brand self-start\" />`;\n }\n if (f.type === 'textarea') {\n return `<textarea data-field-key=\"${key}\" rows=\"3\" class=\"${cls}\" placeholder=\"${escapeHtml(f.placeholder || '')}\">${escapeHtml(String(v))}</textarea>`;\n }\n const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';\n const mono = type === 'text' || type === 'number' ? ' font-mono' : '';\n const minmax = f.type === 'number'\n ? `${f.min != null ? ` min=\"${escapeHtml(String(f.min))}\"` : ''}${f.max != null ? ` max=\"${escapeHtml(String(f.max))}\"` : ''}`\n : '';\n return `<input type=\"${type}\" data-field-key=\"${key}\" value=\"${escapeHtml(String(v))}\" placeholder=\"${escapeHtml(f.placeholder || '')}\"${minmax} class=\"${cls}${mono}\" />`;\n}\n\n// Read the form back into a values object, coercing/validating by field type.\n// Returns null (and focuses the offending control) if a required/number check fails.\nfunction collectPluginFormValues(root, fields) {\n const out = {};\n for (const f of fields) {\n const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key;\n const el = root.querySelector(`[data-field-key=\"${sel}\"]`);\n if (!el) continue;\n let v = f.type === 'checkbox' ? !!el.checked : el.value;\n if (f.type === 'number') {\n if (v === '' || v == null) {\n v = null;\n } else {\n const n = Number(v);\n if (Number.isNaN(n)) { el.focus(); return null; }\n v = n;\n }\n }\n if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; }\n out[f.key] = v;\n }\n return out;\n}\n\n// Open a modal built from a plugin action's field specs; prefill from the plugin,\n// then POST the collected values back through the host.\nasync function openPluginActionForm(pid, action) {\n const fields = Array.isArray(action.fields) ? action.fields : [];\n let values = {};\n if (action.loadOnOpen !== false) {\n try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; }\n catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); }\n }\n const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove();\n const ov = document.createElement('div');\n ov.id = 'pluginActionModal';\n ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md';\n const rows = fields.map((f) => `\n <label class=\"field flex flex-col gap-1.25\">\n <span class=\"field-label text-[11px] font-semibold text-caption uppercase tracking-[0.03em]\">${escapeHtml(f.label || f.key)}</span>\n ${pluginFieldControl(f, values[f.key])}\n ${f.help ? `<span class=\"text-[11px] text-caption leading-[1.4]\">${escapeHtml(f.help)}</span>` : ''}\n </label>`).join('');\n ov.innerHTML = `\n <div class=\"sheet w-[460px] max-w-[92vw] bg-bg-elev backdrop-blur-[40px] border border-window-border rounded-2xl shadow-[0_24px_64px_rgba(0,0,0,0.18)] flex flex-col\">\n <header class=\"flex items-center gap-2 p-[14px_18px] border-b border-border-custom\">\n <h3 class=\"text-[14px] font-semibold tracking-tight\">${escapeHtml(action.label || action.id)}</h3>\n </header>\n <div class=\"p-[18px_20px] flex flex-col gap-3\">\n ${rows}\n <div class=\"flex justify-end gap-2 mt-1\">\n <button data-act=\"cancel\" class=\"btn bg-bg-elev text-muted border border-border-custom rounded-md px-3 py-2 font-medium text-[12px] leading-none cursor-pointer hover:bg-chip-bg hover:text-fg\">${escapeHtml(I18n.t('plugins.cancel'))}</button>\n <button data-act=\"submit\" class=\"btn bg-brand text-white border-none rounded-md px-3.5 py-2 font-semibold text-[12px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\">${escapeHtml(action.submitLabel || I18n.t('plugins.save'))}</button>\n </div>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const close = () => ov.remove();\n ov.addEventListener('click', (e) => { if (e.target === ov) close(); });\n ov.querySelector('[data-act=\"cancel\"]').addEventListener('click', close);\n ov.querySelector('[data-act=\"submit\"]').addEventListener('click', async () => {\n const vals = collectPluginFormValues(ov, fields);\n if (vals == null) return;\n const submitBtn = ov.querySelector('[data-act=\"submit\"]');\n submitBtn.disabled = true;\n try {\n const r = await api.pluginAction(pid, action.id, vals);\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n close();\n await loadPlugins();\n } catch (err) {\n submitBtn.disabled = false;\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n });\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n plugins: 'view-plugins',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'plugins') loadPlugins();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) {\n // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS).\n const ok = await confirmDialog({\n title: I18n.t('providers.delete'),\n message: I18n.t('providers.confirmDelete'),\n confirmText: I18n.t('providers.delete'),\n danger: true,\n });\n if (ok) { config = await api.deleteProvider(btn.dataset.del); renderAll(); }\n return;\n }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.running) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n try {\n config = await api.setActive(card.dataset.id);\n } catch (err) {\n const msg = (err && err.message) || String(err);\n showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err');\n return;\n }\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "483bd8b281c88b5b8fba40f333e30a35ce0867ad": {"t": 1785766818.1336298, "n": 102946, "h": "8de0873699059518", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\n/* ---------- plugins (sidecar coding-agent backends) ---------- */\nconst PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md';\nconst pluginActionsById = {}; // pluginId -> declared actions (for the form modal)\nlet pluginListWired = false;\nasync function loadPlugins() {\n const list = $('pluginList');\n if (!list) return;\n if (!pluginListWired) {\n pluginListWired = true;\n list.addEventListener('click', onPluginAction);\n const bd = $('linkPluginDocs');\n if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} });\n const bi = $('btnPluginInstall');\n if (bi) bi.addEventListener('click', async () => {\n try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); }\n catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); }\n await loadPlugins();\n });\n const bo = $('btnPluginOpenDir');\n if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} });\n const bg = $('btnPluginGit');\n if (bg) bg.addEventListener('click', openPluginGitModal);\n const bgc = $('btnGitCancel');\n if (bgc) bgc.addEventListener('click', closePluginGitModal);\n const bgm = $('pluginGitModal');\n if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); });\n const bgo = $('btnPluginGitGo');\n if (bgo) bgo.addEventListener('click', importFromGit);\n const gu = $('pluginGitUrl');\n if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); });\n }\n let plugins = [];\n try { plugins = await api.pluginList(); }\n catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); }\n const arr = Array.isArray(plugins) ? plugins : [];\n renderPlugins(arr);\n for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); }\n}\nasync function checkPluginUpdate(id) {\n let r;\n try { r = await api.pluginCheckUpdate(id); } catch (_) { return; }\n if (!r || !r.updateAvailable) return;\n const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id;\n const slot = document.querySelector('[data-update-slot=\"' + sel + '\"]');\n if (slot) slot.innerHTML = '<button class=\"text-[10.5px] font-semibold text-amber bg-amber-soft rounded-full px-1.75 py-0.25 border-none cursor-pointer hover:opacity-85\" data-plugin-update=\"' + escapeHtml(id) + '\" title=\"' + escapeHtml(I18n.t('plugins.updateTitle', { ver: r.latest || '' })) + '\">↑ v' + escapeHtml(r.latest || '') + '</button>';\n}\nasync function importFromGit() {\n const u = $('pluginGitUrl');\n const url = ((u && u.value) || '').trim();\n if (!url) return;\n closePluginGitModal();\n showPluginBusy(I18n.t('plugins.importing'));\n try {\n const r = await api.pluginInstallGit(url);\n hidePluginBusy();\n if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok');\n } catch (e) {\n hidePluginBusy();\n showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err');\n }\n await loadPlugins();\n}\nfunction renderPlugins(plugins) {\n const list = $('pluginList');\n if (!list) return;\n const empty = $('emptyPlugins');\n if (empty) empty.classList.toggle('hidden', plugins.length > 0);\n list.innerHTML = '';\n for (const p of plugins) {\n const running = !!p.running;\n const auth = p.auth || {};\n const st = auth.state || '';\n const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : ''))\n : st === 'expired' ? I18n.t('plugins.authExpired')\n : st === 'logged_out' ? I18n.t('plugins.authLoggedOut')\n : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning');\n const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption');\n const iconData = renderProviderIcon(p.name, p.icon);\n const dot = `<span class=\"inline-block w-1.5 h-1.5 rounded-full ${running ? 'bg-green' : 'bg-border-strong'} shrink-0\"></span>`;\n const toggleBtn = `<button class=\"btn btn-sm ${running ? 'bg-red-soft text-red border border-red/18' : 'bg-green-soft text-green border border-green/18'} rounded-md px-2.75 py-1.25 font-semibold text-[11px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\" data-plugin-toggle=\"${escapeHtml(p.id)}\" data-enabled=\"${running ? '1' : '0'}\">${running ? escapeHtml(I18n.t('plugins.disable')) : escapeHtml(I18n.t('plugins.enable'))}</button>`;\n const delBtn = `<button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red\" title=\"${escapeHtml(I18n.t('plugins.deleteTitle'))}\" data-plugin-uninstall=\"${escapeHtml(p.id)}\" data-plugin-name=\"${escapeHtml(p.name || p.id)}\">${I.trash || '⌫'}</button>`;\n // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest.\n const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => {\n if (!a || !a.id) return '';\n // Links open in a browser and never touch the plugin, so they don't need it\n // running; form/call actions default to requiring a running plugin.\n const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false);\n const disabled = needsRun && !running;\n const label = escapeHtml(a.label || a.id);\n return `<button class=\"btn btn-sm bg-bg-elev text-fg border border-border-custom rounded-md px-2.5 py-1.25 font-medium text-[11px] leading-none cursor-pointer hover:bg-chip-bg hover:border-border-strong active:scale-[0.985] disabled:opacity-45 disabled:cursor-not-allowed\" data-plugin-actionbtn=\"${escapeHtml(p.id)}\" data-action-id=\"${escapeHtml(a.id)}\" data-action-kind=\"${escapeHtml(a.kind || 'call')}\"${a.url ? ` data-action-url=\"${escapeHtml(a.url)}\"` : ''}${disabled ? ' disabled' : ''}>${label}</button>`;\n }).join('');\n pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : [];\n const el = document.createElement('div');\n el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong';\n el.dataset.id = p.id;\n el.innerHTML = `\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name || p.id)} <span class=\"text-[10.5px] font-mono text-caption font-normal\">v${escapeHtml(p.version || '')}</span>${p.official ? ' <span class=\"text-[10px] font-semibold text-brand bg-brand-soft rounded-full px-1.5 py-0.25 shrink-0\" title=\"' + escapeHtml(I18n.t('plugins.trusted')) + '\">' + escapeHtml(I18n.t('plugins.trusted')) + '</span>' : ''}<span data-update-slot=\"${escapeHtml(p.id)}\"></span> <span class=\"proto-badge proto-badge-xlate\">${escapeHtml(p.protocol || '')}</span></div>\n <div class=\"mt-0.5 text-xs text-caption truncate\">${escapeHtml(p.description || '')}</div>\n <div class=\"mt-1 flex items-center gap-1.5 text-[11.5px] ${authColor}\" data-plugin-status=\"${escapeHtml(p.id)}\">${dot}<span>${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}</span></div>\n </div>\n <div class=\"flex items-center gap-1.5 shrink-0\">${actionBtns}${toggleBtn}${delBtn}</div>`;\n list.appendChild(el);\n }\n}\nasync function onPluginAction(e) {\n const actionBtn = e.target.closest('[data-plugin-actionbtn]');\n if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; }\n const toggle = e.target.closest('[data-plugin-toggle]');\n const uninstall = e.target.closest('[data-plugin-uninstall]');\n const update = e.target.closest('[data-plugin-update]');\n const btn = toggle || uninstall || update;\n if (!btn) return;\n btn.disabled = true;\n try {\n if (toggle) {\n const enabling = toggle.dataset.enabled !== '1';\n // Starting a sidecar spawns a process and health-gates it (can take a\n // beat), so give the button an immediate spinner + \"Starting…\".\n if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting'));\n markPluginCardBusy(toggle);\n await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling);\n config = await api.getConfig(); // enabling adds a provider, disabling removes it\n renderProviders();\n } else if (uninstall) {\n const ok = await confirmDialog({\n title: I18n.t('plugins.deleteTitle'),\n message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }),\n confirmText: I18n.t('plugins.confirmDelete'),\n danger: true,\n });\n if (!ok) { uninstall.disabled = false; return; }\n const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall);\n if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); }\n } else if (update) {\n update.disabled = true;\n await api.pluginUpdate(update.dataset.pluginUpdate);\n config = await api.getConfig(); renderProviders();\n }\n } catch (err) {\n showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err');\n }\n await loadPlugins();\n}\nfunction openPluginGitModal() {\n const m = $('pluginGitModal'); if (!m) return;\n m.classList.remove('hidden');\n const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); }\n}\nfunction closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); }\n// Full-screen blocking overlay shown while a git clone/build runs — the user\n// cannot interact with the rest of the app until it finishes.\nfunction showPluginBusy(text) {\n let ov = document.getElementById('pluginBusy');\n if (!ov) {\n ov = document.createElement('div');\n ov.id = 'pluginBusy';\n ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md';\n ov.style.background = 'rgba(0,0,0,0.45)';\n ov.innerHTML = '<div class=\"animate-spin\" style=\"width:34px;height:34px;border:3px solid rgba(255,255,255,0.28);border-top-color:#fff;border-radius:50%\"></div><p id=\"pluginBusyText\" style=\"color:#fff;margin-top:14px;font-size:13px;font-weight:600\"></p>';\n document.body.appendChild(ov);\n }\n const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || '';\n ov.style.display = 'flex';\n}\nfunction hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; }\n\n// Small inline spinner used for per-button loading (enable/start).\nfunction pluginSpinner() {\n return '<span class=\"animate-spin inline-block w-3 h-3 rounded-full border-[1.5px] border-current border-t-transparent align-[-2px]\"></span>';\n}\n// Turn a button into a busy state: spinner + text, disabled. Reset happens on the\n// next renderPlugins() (loadPlugins re-renders the whole list from fresh status).\nfunction setPluginBtnBusy(btn, text) {\n if (!btn) return;\n btn.disabled = true;\n btn.innerHTML = `<span class=\"inline-flex items-center gap-1.25\">${pluginSpinner()}${escapeHtml(text || '')}</span>`;\n}\n// Dim the card and swap its status line to a \"starting\" spinner while the sidecar\n// spins up, so the whole row reads as in-progress (not just the button).\nfunction markPluginCardBusy(btn) {\n const card = btn && btn.closest('.plugin');\n if (!card) return;\n card.classList.add('opacity-60', 'pointer-events-none');\n const line = card.querySelector('[data-plugin-status]');\n if (line) line.innerHTML = `${pluginSpinner()}<span>${escapeHtml(I18n.t('plugins.starting'))}</span>`;\n}\n\n// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ----\nasync function runPluginDeclaredAction(btn) {\n if (btn.disabled) return;\n const pid = btn.dataset.pluginActionbtn;\n const actionId = btn.dataset.actionId;\n const kind = btn.dataset.actionKind || 'call';\n if (kind === 'link') {\n const url = btn.dataset.actionUrl;\n if (url) { try { api.openExternal(url); } catch (_) {} }\n return;\n }\n const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId };\n if (kind === 'form') { await openPluginActionForm(pid, action); return; }\n // kind === 'call': fire-and-report, with an optional confirm gate\n if (action.confirm && !window.confirm(action.confirm)) return;\n btn.disabled = true;\n try {\n const r = await api.pluginAction(pid, actionId, {});\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n } catch (err) {\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n btn.disabled = false;\n await loadPlugins();\n}\n\n// Build one form control from a field spec. Recognized types: text (default),\n// number, password, textarea, select, checkbox.\nfunction pluginFieldControl(f, val) {\n const key = escapeHtml(f.key);\n const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary';\n const v = (val != null ? val : (f.default != null ? f.default : ''));\n if (f.type === 'select') {\n const opts = (Array.isArray(f.options) ? f.options : []).map((o) => {\n const ov = (o && typeof o === 'object') ? o.value : o;\n const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;\n const sel = String(ov) === String(v) ? ' selected' : '';\n return `<option value=\"${escapeHtml(String(ov))}\"${sel}>${escapeHtml(String(ol))}</option>`;\n }).join('');\n return `<select data-field-key=\"${key}\" class=\"${cls}\">${opts}</select>`;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return `<input type=\"checkbox\" data-field-key=\"${key}\" ${on ? 'checked' : ''} class=\"w-4 h-4 accent-brand self-start\" />`;\n }\n if (f.type === 'textarea') {\n return `<textarea data-field-key=\"${key}\" rows=\"3\" class=\"${cls}\" placeholder=\"${escapeHtml(f.placeholder || '')}\">${escapeHtml(String(v))}</textarea>`;\n }\n const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';\n const mono = type === 'text' || type === 'number' ? ' font-mono' : '';\n const minmax = f.type === 'number'\n ? `${f.min != null ? ` min=\"${escapeHtml(String(f.min))}\"` : ''}${f.max != null ? ` max=\"${escapeHtml(String(f.max))}\"` : ''}`\n : '';\n return `<input type=\"${type}\" data-field-key=\"${key}\" value=\"${escapeHtml(String(v))}\" placeholder=\"${escapeHtml(f.placeholder || '')}\"${minmax} class=\"${cls}${mono}\" />`;\n}\n\n// Read the form back into a values object, coercing/validating by field type.\n// Returns null (and focuses the offending control) if a required/number check fails.\nfunction collectPluginFormValues(root, fields) {\n const out = {};\n for (const f of fields) {\n const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key;\n const el = root.querySelector(`[data-field-key=\"${sel}\"]`);\n if (!el) continue;\n let v = f.type === 'checkbox' ? !!el.checked : el.value;\n if (f.type === 'number') {\n if (v === '' || v == null) {\n v = null;\n } else {\n const n = Number(v);\n if (Number.isNaN(n)) { el.focus(); return null; }\n v = n;\n }\n }\n if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; }\n out[f.key] = v;\n }\n return out;\n}\n\n// Open a modal built from a plugin action's field specs; prefill from the plugin,\n// then POST the collected values back through the host.\nasync function openPluginActionForm(pid, action) {\n const fields = Array.isArray(action.fields) ? action.fields : [];\n let values = {};\n if (action.loadOnOpen !== false) {\n try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; }\n catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); }\n }\n const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove();\n const ov = document.createElement('div');\n ov.id = 'pluginActionModal';\n ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md';\n const rows = fields.map((f) => `\n <label class=\"field flex flex-col gap-1.25\">\n <span class=\"field-label text-[11px] font-semibold text-caption uppercase tracking-[0.03em]\">${escapeHtml(f.label || f.key)}</span>\n ${pluginFieldControl(f, values[f.key])}\n ${f.help ? `<span class=\"text-[11px] text-caption leading-[1.4]\">${escapeHtml(f.help)}</span>` : ''}\n </label>`).join('');\n ov.innerHTML = `\n <div class=\"sheet w-[460px] max-w-[92vw] bg-bg-elev backdrop-blur-[40px] border border-window-border rounded-2xl shadow-[0_24px_64px_rgba(0,0,0,0.18)] flex flex-col\">\n <header class=\"flex items-center gap-2 p-[14px_18px] border-b border-border-custom\">\n <h3 class=\"text-[14px] font-semibold tracking-tight\">${escapeHtml(action.label || action.id)}</h3>\n </header>\n <div class=\"p-[18px_20px] flex flex-col gap-3\">\n ${rows}\n <div class=\"flex justify-end gap-2 mt-1\">\n <button data-act=\"cancel\" class=\"btn bg-bg-elev text-muted border border-border-custom rounded-md px-3 py-2 font-medium text-[12px] leading-none cursor-pointer hover:bg-chip-bg hover:text-fg\">${escapeHtml(I18n.t('plugins.cancel'))}</button>\n <button data-act=\"submit\" class=\"btn bg-brand text-white border-none rounded-md px-3.5 py-2 font-semibold text-[12px] leading-none cursor-pointer hover:opacity-90 active:scale-[0.985]\">${escapeHtml(action.submitLabel || I18n.t('plugins.save'))}</button>\n </div>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const close = () => ov.remove();\n ov.addEventListener('click', (e) => { if (e.target === ov) close(); });\n ov.querySelector('[data-act=\"cancel\"]').addEventListener('click', close);\n ov.querySelector('[data-act=\"submit\"]').addEventListener('click', async () => {\n const vals = collectPluginFormValues(ov, fields);\n if (vals == null) return;\n const submitBtn = ov.querySelector('[data-act=\"submit\"]');\n submitBtn.disabled = true;\n try {\n const r = await api.pluginAction(pid, action.id, vals);\n showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok');\n close();\n await loadPlugins();\n } catch (err) {\n submitBtn.disabled = false;\n showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err');\n }\n });\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n plugins: 'view-plugins',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'plugins') loadPlugins();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.running) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n try {\n config = await api.setActive(card.dataset.id);\n } catch (err) {\n const msg = (err && err.message) || String(err);\n showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err');\n return;\n }\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "dddada49a436376df977786592f69242cb2fdc69": {"t": 1785766819.6524196, "n": 83730, "h": "475cfcf1ed253166", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\" data-clarity-mask=\"true\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "c9065e82cadcf49bb074049898b2e92538496534": {"t": 1785766821.3200376, "n": 83705, "h": "9235e346ba1b1b3b", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets\n // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation\n // the recording captures, keeping highlight colors faithful in replay.\n if (hd) hd.media = dark ? 'all' : 'not all';\n if (hl) hl.media = dark ? 'not all' : 'all';\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "720688af9d717979e2ac2cad29fcd94f72927e8c": {"t": 1785766824.0250695, "n": 83421, "h": "4163d22a3e5103dc", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays.\n host.setAttribute('data-clarity-mask', 'true');\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "2fdbf6eace10dc8c72b8dae33a5e7c03dd54c181": {"t": 1785766825.8008988, "n": 83262, "h": "061e4a597028eff5", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\n// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action\n// (\"一键接入\"/\"断开\" writes or restores the CLIs' configs) — independent of the service switch.\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc');\n if (status.running) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.running);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.running);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false;\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer).\n while (list.children.length > 100) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\n// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps\n// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer:\n// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body)\n// req — what the gateway SENT upstream (real upstream URL/headers/translated body)\n// ures — what the upstream RETURNED (raw, pre-translation)\n// res — what the gateway RETURNED to the client (translated)\nfunction drawerTabView(d, tab) {\n const creq = d.clientReq || {};\n const ures = d.upstreamRes || {};\n switch (tab) {\n case 'creq':\n return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` };\n case 'ures':\n return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` };\n case 'res':\n return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` };\n default: // 'req'\n return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` };\n }\n}\nfunction drawerTabs(d) {\n return d && d.translated\n ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')],\n ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]]\n : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]];\n}\nconst DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand';\nfunction renderDrawerTabs() {\n const wrap = $('drTabs');\n if (!wrap) return;\n wrap.innerHTML = drawerTabs(reqDrawerData)\n .map(([k, label]) => `<button class=\"${DR_TAB_CLS}${k === reqDrawerTab ? ' active' : ''}\" data-tab=\"${k}\">${escapeHtml(label)}</button>`)\n .join('');\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const view = drawerTabView(d, reqDrawerTab);\n const isReq = view.isReq;\n const headers = view.headers;\n const cap = view.cap;\n const which = reqDrawerTab;\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(view.sub)}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n // Translated exchanges open on the client request (what the gateway received) so the\n // before/after of the translation reads left-to-right across the tabs.\n reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n d.translated ? [I18n.t('drawer.translated'), d.translated] : null,\n d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null,\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n renderDrawerTabs();\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\n// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in\n// Settings → connect targets.\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const on = !status.running;\n btn.disabled = true;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n btn.disabled = false;\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n if (res && res.ok === false) {\n showHeroNote(res.message || I18n.t('err.opFailed'), true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => {\n const on = e.target.checked;\n let res;\n try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n e.target.checked = !on; // couldn't bind the port → revert + surface\n try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {}\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n });\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container.\n const drTabsWrap = $('drTabs');\n if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => {\n const t = e.target.closest('.dr-tab');\n if (!t) return;\n reqDrawerTab = t.dataset.tab;\n drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n });\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "8f92c033e5a8ca6439bad93c4f085860c0e5774d": {"t": 1785766828.35577, "n": 80263, "h": "40f1218dd3e6d119", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\n// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic`\n// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset\n// sets the protocol so the user knows immediately how their requests will be handled.\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' },\n nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' },\n openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' },\n openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] };\n for (const file in brand) {\n // object-fit:contain keeps non-square logos from being stretched into the square icon slot.\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block;object-fit:contain\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\n// Multi-select for which coding CLIs \"一键接入\" wires to the gateway. Each toggle reflects\n// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled\n// (with a note) until it's installed.\nfunction renderConnectTargets() {\n // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection.\n const cc = $('fTargetClaude'), cx = $('fTargetCodex');\n if (cc) cc.checked = !!status.connectedClaude;\n if (cx) cx.checked = !!status.connectedCodex;\n const codexOk = status.codexAvailable !== false;\n const row = $('targetCodexRow'), note = $('targetCodexNote');\n if (cx) cx.disabled = !codexOk;\n if (row) row.style.opacity = codexOk ? '1' : '0.55';\n if (note) note.style.display = codexOk ? 'none' : '';\n const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); };\n chip($('tgtClaudeChip'), !!status.connectedClaude);\n chip($('tgtCodexChip'), !!status.connectedCodex);\n}\n// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or\n// stops the gateway as needed), so unchecking Claude Code actually turns it off.\nasync function toggleTarget(target, on) {\n if (!api.setConnectTarget) return;\n let res;\n try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; }\n if (res && res.ok === false) {\n // couldn't turn on (no provider / port) → revert the switch + surface the reason\n const msg = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider') : (res.message || I18n.t('err.opFailed'));\n try { showHeroNote(msg, true); } catch (_) {}\n const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude');\n if (el) el.checked = !on;\n return;\n }\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n renderConnectTargets();\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep\n // them out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n // Protocol badge so the wire protocol (and whether requests are translated) is visible at a\n // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones\n // stand out.\n const proto = p.protocol || 'anthropic';\n const protoMeta = proto === 'openai-chat'\n ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' }\n : proto === 'openai-responses'\n ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' }\n : { label: 'Anthropic', cls: 'proto-badge-direct' };\n const protoBadge = `<span class=\"proto-badge ${protoMeta.cls}\" title=\"${escapeHtml(I18n.t('providers.protocolTip'))}\">${escapeHtml(protoMeta.label)}</span>`;\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${protoBadge} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\n// Segmented protocol control: get/set the selected wire protocol.\nfunction getProtocol() {\n const g = $('fProtocol'); if (!g) return 'anthropic';\n const b = g.querySelector('.proto-seg-btn.selected');\n return (b && b.dataset.proto) || 'anthropic';\n}\nfunction setProtocol(v) {\n const g = $('fProtocol'); if (!g) return;\n v = v || 'anthropic';\n g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v));\n syncProtocolHint();\n}\n// Reflect the chosen protocol as a prominent status line so the user always knows whether their\n// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses).\nfunction syncProtocolHint() {\n const badge = $('protoBadge');\n if (!badge) return;\n const v = getProtocol();\n const map = {\n 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' },\n 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' },\n };\n const m = map[v] || map['anthropic'];\n badge.className = 'proto-badge ' + m.cls;\n badge.textContent = I18n.t(m.k);\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n setProtocol((provider && provider.protocol) || 'anthropic');\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n protocol: getProtocol(),\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) {\n const m = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : (res.message || I18n.t('err.opFailed'));\n showHeroNote(m, true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked));\n if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); }\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "fb7ffbf8d9924018addc95f71048c3bc40b5bf80": {"t": 1785766829.892562, "n": 74724, "h": "9b75ea7536b81cec", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\n// Floating toast — sits above modals/drawers (z above everything) so a result is never\n// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss.\n// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the\n// compiled CSS state.\nfunction ensureToastHost() {\n let host = document.getElementById('toastHost');\n if (!host) {\n host = document.createElement('div');\n host.id = 'toastHost';\n host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;';\n document.body.appendChild(host);\n }\n return host;\n}\nfunction showToast(text, type, opts) {\n opts = opts || {};\n const host = ensureToastHost();\n const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)';\n const el = document.createElement('div');\n el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`;\n el.textContent = text;\n const dismiss = () => {\n if (el._gone) return;\n el._gone = true;\n clearTimeout(el._t);\n el.style.transition = 'opacity 0.18s ease, transform 0.18s ease';\n el.style.opacity = '0';\n el.style.transform = 'translateY(-6px)';\n setTimeout(() => el.remove(), 180);\n };\n el.addEventListener('click', dismiss);\n el.dismiss = dismiss;\n host.appendChild(el);\n // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer).\n const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500);\n if (ttl) el._t = setTimeout(dismiss, ttl);\n return el;\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic \"导入\" store is app-managed (not a user config dir) — keep it out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) {\n const m = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : (res.message || I18n.t('err.opFailed'));\n showHeroNote(m, true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n showToast(I18n.t('modal.fillUrl'), 'err');\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n // Surface the result as a floating toast — the in-modal alert sits at the bottom of a\n // scrollable sheet and was easily hidden, leaving users unsure whether the test ran.\n const pending = showToast(I18n.t('modal.testing'), 'pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n if (pending) pending.dismiss();\n showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err');\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "583308bbecfa7110c6c9519e03a6b32b2c55210f": {"t": 1785766831.7309005, "n": 73095, "h": "730568ca08546f1b", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic \"导入\" store is app-managed (not a user config dir) — keep it out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) {\n const m = res.reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : (res.message || I18n.t('err.opFailed'));\n showHeroNote(m, true);\n }\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n let msg;\n if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty');\n else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid');\n else if (res.reason === 'timeout') msg = I18n.t('err.timeout');\n else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' });\n else msg = res.message || ('HTTP ' + (res.status || ''));\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + msg;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "d6daf01438c703a6b8f8288d30ed5f81dff69f03": {"t": 1785766835.6834164, "n": 72604, "h": "7119a0c8040998b7", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic \"导入\" store is app-managed (not a user config dir) — keep it out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); } });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "ff7cb6e0ca7756b31657041563518b836928a2bd": {"t": 1785766838.7307057, "n": 81623, "h": "a6233f13ee66c151", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n // Bundled per-platform → always supported; just reflect the runtime state.\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic \"导入\" store is app-managed (not a user config dir) — keep it out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "f8a76201303a24c53351bfbf424afa8c7398bfdc": {"t": 1785766840.268275, "n": 81652, "h": "12eef0b8c526c8ef", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n // The synthetic \"导入\" store is app-managed (not a user config dir) — keep it out of this list.\n const dirs = (data.dirs || []).filter((d) => !d.imported);\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "54bbc011441170081b6764c75a93377994996297": {"t": 1785766842.0106878, "n": 81526, "h": "4eda96e1704e0228", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- in-app updates ---------- */\nlet updateState = null;\nlet updateBusy = false;\nfunction show(el, on) { if (el) el.classList.toggle('hidden', !on); }\nfunction renderUpdate() {\n const s = updateState;\n const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip');\n const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'),\n bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes');\n if (!verEl) return;\n if (s) {\n verEl.textContent = s.runningVersion || s.shellVersion || '—';\n latEl.textContent = s.latestVersion || '—';\n }\n // reset\n [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false));\n show(actions, false); show(notes, false); show(chip, false);\n if (chip) chip.classList.remove('text-green', 'text-amber');\n\n if (!s) { stEl.textContent = I18n.t('about.idle'); return; }\n const staged = s.pending && s.pending.staged;\n if (staged) {\n stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version });\n chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true);\n show(actions, true); show(bApply, true);\n return;\n }\n if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; }\n if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; }\n if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; }\n\n // an update is available\n chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true);\n show(actions, true);\n if (s.notes) { notes.textContent = s.notes; show(notes, true); }\n if (s.mode === 'hot') {\n stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion });\n show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall');\n } else { // full\n stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion });\n show(bOpen, true);\n if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); }\n }\n}\nasync function loadUpdateState() {\n try { updateState = await api.updateState(); } catch (_) {}\n syncAutoToggles();\n renderUpdate();\n}\nasync function checkUpdate() {\n const btn = $('btnUpdateCheck');\n if (btn) { btn.disabled = true; }\n $('updStatus').textContent = I18n.t('about.checking');\n try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; }\n if (btn) btn.disabled = false;\n renderUpdate();\n}\nasync function downloadUpdate() {\n updateBusy = true; renderUpdate();\n let res;\n try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; }\n updateBusy = false;\n try { updateState = await api.updateState(); } catch (_) {}\n if (res && !res.ok && updateState) updateState.error = res.error;\n renderUpdate();\n}\nfunction syncAutoToggles() {\n const au = (config && config.autoUpdate) || {};\n const c = $('fAutoCheck'), d = $('fAutoDownload');\n if (c) c.checked = au.check !== false;\n if (d) d.checked = au.autoDownload !== false;\n}\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n if (pane === 'about') loadUpdateState();\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n\n // ----- in-app updates -----\n const bUpdCheck = $('btnUpdateCheck');\n if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate);\n const bUpdDl = $('btnUpdateDownload');\n if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate);\n const bUpdApply = $('btnUpdateApply');\n if (bUpdApply) bUpdApply.addEventListener('click', async () => {\n const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') });\n if (ok) api.updateApply();\n });\n const bUpdOpen = $('btnUpdateOpen');\n if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest'));\n const bUpdBrew = $('btnUpdateBrew');\n if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud'));\n const bRepo = $('btnRepo');\n if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud'));\n const bReleases = $('btnReleases');\n if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases'));\n const fAutoCheck = $('fAutoCheck');\n if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); });\n const fAutoDownload = $('fAutoDownload');\n if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); });\n\n if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); });\n if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); });\n if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "b2f5f6ac09b45acde1723450b465ec0392aa778a": {"t": 1785766843.6995723, "n": 76034, "h": "019337628a532732", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false);\n if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify;\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) }));\n if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "5876f010d145b3231670f4fc0e0ee9788cc6d0e6": {"t": 1785766846.5974581, "n": 75557, "h": "f0fbc3d9529667fa", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "ec8b6e62237868aa047273fda6f7b8b75c3c00e0": {"t": 1785766848.2959192, "n": 75368, "h": "73ef2cc9586675a4", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "50ea938d992d695854169d254340d5eacdeb4f7d": {"t": 1785766850.2356918, "n": 75463, "h": "34a646e3b381131a", "c": "'use strict';\n\nconst api = window.ccbud;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ccbudIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('ccbud-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('ccbud-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('ccbud-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['ccbud-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while ccbud was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "4e5278f0c90d128a9e17010fa7a8dd02abd5d55f": {"t": 1785766851.7944703, "n": 75482, "h": "59c23d575457654b", "c": "'use strict';\n\nconst api = window.clawdy;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ClawdyIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" alt=\"\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n // a11y: the button's accessible name must contain its visible text (localhost:port).\n const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`);\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'clawdy-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\" title=\"${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[340px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('clawdy-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'clawdy_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a\n // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced).\n requestAnimationFrame(() => {\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => { if (target) target.style.transition = ''; }, 280);\n });\n }\n\n if (view === 'conversations' && window.ClawdyConversations) window.ClawdyConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide\n // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300.\n if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900);\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('clawdy-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n // Theme-toggle icon reflects the current mode: sun in light, moon in dark.\n const tbIcon = document.querySelector('#btnTheme [data-icon]');\n if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; }\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('clawdy-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('clawdy-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('clawdy-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('clawdy-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['clawdy-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ClawdyConversations && window.ClawdyConversations.setLang) window.ClawdyConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while Clawdy was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('clawdy-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('clawdy-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "df66132cf200d4a4b1140c8fa54cbfd15e055207": {"t": 1785766853.4278948, "n": 74419, "h": "abd36a7c5849043a", "c": "'use strict';\n\nconst api = window.clawdy;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL)\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ClawdyIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash' },\n mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5' },\n kimi: { name: 'Kimi', baseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays\n// consistent across language switches and the timestamp never wraps with a 12-hour \"PM\" suffix.\nfunction fmtTime(d) {\n const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d));\n const p = (n) => String(n).padStart(2, '0');\n return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;\n}\nfunction fmtNum(n) {\n n = n || 0;\n if (n < 1000) return String(n);\n if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\\.0$/, '') + 'K';\n if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\\.0$/, '') + 'M';\n return (n / 1e9).toFixed(1).replace(/\\.0$/, '') + 'B';\n}\n// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio=\"none\".\nfunction sparkSVG(vals) {\n const W = 300, H = 46, pad = 4;\n const data = (vals && vals.length) ? vals : [0, 0];\n const n = data.length;\n const max = Math.max(1, ...data);\n const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));\n const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2);\n const pts = data.map((v, i) => [xs(i), ys(v)]);\n let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;\n for (let i = 0; i < pts.length - 1; i++) {\n const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;\n }\n const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`;\n return `<svg viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" style=\"width:100%;height:100%;display:block\"><defs><linearGradient id=\"hsg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"var(--brand)\" stop-opacity=\"0.30\"/><stop offset=\"1\" stop-color=\"var(--brand)\" stop-opacity=\"0\"/></linearGradient></defs><path d=\"${area}\" fill=\"url(#hsg)\"/><path d=\"${line}\" fill=\"none\" stroke=\"var(--brand)\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\" vector-effect=\"non-scaling-stroke\"/></svg>`;\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\n// Deterministic \"random\" emoji set — a custom provider with no brand logo gets a stable one.\nconst ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊'];\nfunction emojiIcon(emoji, name) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `<span class=\"prov-emoji\">${escapeHtml(emoji)}</span>` };\n}\n// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji.\nfunction renderProviderIcon(name, icon) {\n if (icon && typeof icon === 'string') {\n if (/^(data:|https?:|assets\\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"${escapeHtml(icon)}\" class=\"prov-svg\" style=\"width:100%;height:100%;object-fit:cover;display:block\" />` };\n return emojiIcon(icon, name); // a chosen emoji\n }\n const n = (name || '').trim().toLowerCase();\n const brand = { kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'] };\n for (const file in brand) {\n if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `<img src=\"assets/${file}.svg\" class=\"prov-svg\" style=\"width:100%;height:100%;display:block\" />` };\n }\n if (n.includes('claude') || n.includes('anthropic')) {\n const h = hashHue(name || '?');\n return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `<svg class=\"prov-svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 2v20 M3 12h18\" stroke-linecap=\"round\"/></svg>` };\n }\n return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji\n}\nfunction mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n if (n) {\n n.textContent = text;\n n.classList.remove('hidden');\n n.classList.toggle('warn', !!warn);\n }\n}\nfunction hideHeroNote() {\n const n = $('heroNote');\n if (n) {\n n.classList.add('hidden');\n }\n}\n\nlet heroRange = '30d';\nasync function renderHeroUsage() {\n const wrap = $('heroUsage');\n if (!wrap || !api.usageGet) return;\n let u; try { u = await api.usageGet(heroRange); } catch (_) { return; }\n if (!u) return;\n const port = (status.running && status.port) || config.port;\n const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`;\n const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0);\n const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() });\n const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : '';\n const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90;\n const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0);\n const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series);\n}\nlet _heroUsageT = null;\nfunction scheduleHeroUsage() {\n clearTimeout(_heroUsageT);\n _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500);\n}\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n const icon = $('heroIcon');\n if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; }\n else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; }\n $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.connected');\n $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running');\n $('btnConnect').textContent = I18n.t('hero.disconnect');\n hideHeroNote();\n $('heroUsage').classList.remove('hidden');\n renderHeroUsage();\n } else {\n hero.classList.remove('connected');\n const icon = $('heroIcon');\n icon.removeAttribute('style');\n icon.innerHTML = I.connect || '';\n $('heroTitle').textContent = I18n.t('hero.titleIdle');\n $('heroSub').textContent = I18n.t('hero.subIdle');\n $('btnConnect').textContent = I18n.t('hero.connect');\n hideHeroNote();\n $('heroUsage').classList.add('hidden');\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (chip) {\n chip.classList.toggle('on', !!status.connected);\n const txt = chip.querySelector('.status-text');\n if (txt) {\n txt.textContent = I18n.t(status.connected ? 'status.connected' : 'status.disconnected');\n }\n const bt = $('brandTitle');\n if (bt) {\n bt.classList.toggle('running', !!status.connected);\n }\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'clawdy-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n I18n.t('settings.exportHint'),\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n if ($('fLang')) $('fLang').value = config.language || I18n.lang;\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n renderHistoryDirs();\n renderDesktopCard();\n renderPresidioCard();\n}\n\n/* ---------- Claude Desktop (\"Third-Party Inference\") integration ---------- */\nasync function renderDesktopCard() {\n const card = $('desktopCard');\n if (!card || !api.desktopStatus) return;\n let st;\n try { st = await api.desktopStatus(); } catch (_) { return; }\n const chip = $('desktopStatusChip');\n const btn = $('btnDesktopConnect');\n const note = $('desktopNote');\n if (!chip || !btn || !note) return;\n chip.className = 'desktop-chip text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + (st.connected ? 'bg-green-soft text-green' : 'bg-chip-bg text-muted');\n if (!st.supported || !st.installed) {\n card.classList.add('opacity-60');\n btn.disabled = true;\n btn.dataset.connected = '';\n btn.textContent = I18n.t('settings.desktopConnect');\n chip.textContent = I18n.t(st.supported ? 'settings.desktopNotInstalled' : 'settings.desktopUnsupported');\n delete note.dataset.transient;\n note.textContent = I18n.t(st.supported ? 'settings.desktopNote' : 'settings.desktopUnsupportedNote');\n return;\n }\n card.classList.remove('opacity-60');\n btn.disabled = false;\n btn.dataset.connected = st.connected ? '1' : '';\n btn.textContent = I18n.t(st.connected ? 'settings.desktopRestore' : 'settings.desktopConnect');\n chip.textContent = I18n.t(st.connected ? 'settings.desktopConnected' : 'settings.desktopDisconnected');\n // The post-action guidance (steps / restored) is marked transient so a status re-render\n // doesn't wipe it before the user has gone to System Settings.\n if (st.connected) { delete note.dataset.transient; note.textContent = I18n.t('settings.desktopConnectedNote'); }\n else if (!note.dataset.transient) { note.textContent = I18n.t('settings.desktopNote'); }\n}\n\n// The Claude Desktop status is read from the installed system profile (not a live event), so\n// poll lightly while the Settings view is open — picks up an install/removal within a few seconds.\nlet desktopPollTimer = null;\nfunction startDesktopPoll() {\n stopDesktopPoll();\n renderDesktopCard(); renderPresidioCard();\n desktopPollTimer = setInterval(() => { renderDesktopCard(); renderPresidioCard(); }, 4000);\n}\nfunction stopDesktopPoll() {\n if (desktopPollTimer) { clearInterval(desktopPollTimer); desktopPollTimer = null; }\n}\n\n/* ---------- Presidio (local PII content filter) ---------- */\nasync function renderPresidioCard() {\n const card = $('presidioCard');\n if (!card || !api.presidioStatus) return;\n let st;\n try { st = await api.presidioStatus(); } catch (_) { return; }\n const chip = $('presidioStatusChip');\n const sw = $('fPresidio');\n const note = $('presidioNote');\n if (!chip || !sw || !note) return;\n const enabled = !!(config.presidio && config.presidio.enabled);\n if (document.activeElement !== sw) sw.checked = enabled;\n // Tier sub-options (regex auto-on; NER / LLM opt-in).\n const tiers = $('presidioTiers');\n if (tiers) tiers.classList.toggle('hidden', !enabled);\n const px = config.presidio || {};\n const nerSw = $('fPresidioNer'), llmSw = $('fPresidioLlm'), ollama = $('fOllamaUrl');\n if (nerSw && document.activeElement !== nerSw) nerSw.checked = !!px.ner;\n if (llmSw && document.activeElement !== llmSw) llmSw.checked = !!px.llm;\n if (ollama) { ollama.classList.toggle('hidden', !px.llm); if (document.activeElement !== ollama) ollama.value = px.ollamaUrl || ''; }\n // advanced controls\n const deid = $('fPresidioDeid'); if (deid && document.activeElement !== deid) deid.value = px.deidentify || 'replace';\n const thr = $('fPresidioThreshold'), thrVal = $('fPresidioThresholdVal');\n const tv = (typeof px.threshold === 'number') ? px.threshold : 0.4;\n if (thr && document.activeElement !== thr) thr.value = tv;\n if (thrVal) thrVal.textContent = tv.toFixed(2);\n const nerM = $('fPresidioNerModel'); if (nerM && document.activeElement !== nerM) nerM.value = px.nerModel || 'en_core_web_sm';\n let txt, cls = 'bg-chip-bg text-muted';\n if (!st.supported) { sw.disabled = true; txt = 'settings.desktopUnsupported'; }\n else {\n sw.disabled = false;\n if (!enabled) txt = 'settings.presidioOff';\n else if (st.setup === 'installing') { txt = 'settings.presidioInstalling'; cls = 'bg-amber-soft text-amber'; }\n else if (st.setup === 'missing-source') { txt = 'settings.presidioNoSource'; cls = 'bg-amber-soft text-amber'; }\n else if (st.running) { txt = 'settings.presidioRunning'; cls = 'bg-green-soft text-green'; }\n else { txt = 'settings.presidioStarting'; cls = 'bg-amber-soft text-amber'; }\n }\n chip.className = 'text-[10.5px] font-semibold rounded-full px-2 py-0.25 ' + cls;\n chip.textContent = I18n.t(txt);\n const dot = $('presidioLogDot');\n if (dot) dot.style.background = (enabled && st.running) ? '#34c759' : (enabled ? '#ff9f0a' : '#3a3f4b');\n if (enabled && st.running) { delete note.dataset.transient; note.textContent = I18n.t('settings.presidioRunningNote'); }\n else if (!note.dataset.transient) note.textContent = I18n.t(enabled && st.setup === 'installing' ? 'settings.presidioInstallingNote' : 'settings.presidioNote');\n}\n\n// Presidio console — live service output, so \"启动中\" is never a mystery.\nfunction appendPresidioLog(line) {\n const pre = $('presidioLog');\n if (!pre) return;\n const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;\n pre.textContent += (pre.textContent ? '\\n' : '') + line;\n if (nearBottom) pre.scrollTop = pre.scrollHeight;\n}\nfunction renderPresidioLog() {\n const pre = $('presidioLog');\n if (!pre || !api.presidioLogs) return;\n api.presidioLogs().then((lines) => {\n pre.textContent = (lines || []).join('\\n');\n pre.scrollTop = pre.scrollHeight;\n }).catch(() => {});\n}\n// Presidio console tabs: console output ↔ Findings table (detected PII).\nlet presidioTab = 'console';\nfunction setPresidioTab(t) {\n presidioTab = t;\n document.querySelectorAll('#presidioConsoleTabs .ptab').forEach((b) => b.classList.toggle('active', b.dataset.ptab === t));\n document.querySelectorAll('#presidioConsole [data-ptab-panel]').forEach((p) => p.classList.toggle('hidden', p.dataset.ptabPanel !== t));\n if (t === 'findings') renderPresidioFindings();\n}\nfunction updateFindCount(n) {\n const c = $('presidioFindCount');\n if (!c) return;\n c.textContent = n > 99 ? '99+' : String(n);\n c.classList.toggle('hidden', !n);\n}\nfunction findingsTableHTML(rows) {\n if (!rows || !rows.length) return `<div class=\"fd-empty\">${escapeHtml(I18n.t('settings.presidioFindNone'))}</div>`;\n const head = `<thead><tr><th>${escapeHtml(I18n.t('settings.findEntity'))}</th><th>${escapeHtml(I18n.t('settings.findText'))}</th><th style=\"text-align:right\">${escapeHtml(I18n.t('settings.findScore'))}</th></tr></thead>`;\n const body = rows.slice().reverse().map((f) => `<tr><td class=\"fd-entity\">${escapeHtml(f.entity || '')}</td><td class=\"fd-text\">${escapeHtml(f.text || '')}</td><td class=\"fd-score\">${f.score != null ? f.score : ''}</td></tr>`).join('');\n return `<table>${head}<tbody>${body}</tbody></table>`;\n}\nfunction renderPresidioFindings() {\n const host = $('presidioFindings');\n if (!host || !api.presidioFindings) return;\n api.presidioFindings().then((rows) => {\n host.innerHTML = findingsTableHTML(rows || []);\n updateFindCount((rows || []).length);\n }).catch(() => {});\n}\nfunction refreshFindCount() {\n if (!api.presidioFindings) return;\n api.presidioFindings().then((rows) => updateFindCount((rows || []).length)).catch(() => {});\n}\nfunction appendFinding() {\n const c = $('presidioFindCount');\n if (c) updateFindCount((parseInt(c.textContent, 10) || 0) + 1);\n if (presidioTab === 'findings') renderPresidioFindings();\n}\n\nasync function renderHistoryDirs() {\n const host = $('histDirList');\n if (!host) return;\n let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; }\n const dirs = data.dirs || [];\n host.innerHTML = dirs.map((d) => {\n const status = d.exists === false\n ? `<span class=\"hist-dir-warn text-red font-semibold text-[11px] shrink-0 bg-red-soft px-2.5 py-0.75 rounded-full\" title=\"${escapeHtml(I18n.t('settings.dirMissing'))}\">${escapeHtml(I18n.t('settings.dirMissing'))}</span>`\n : `<span class=\"hist-dir-count text-brand font-semibold text-[11px] shrink-0 bg-brand-soft px-2.5 py-0.75 rounded-full\">${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}</span>`;\n return `<div class=\"hist-dir-row flex items-center gap-3.5 p-3 px-4 bg-bg-elev border border-border-custom rounded-md text-[13px] shadow-sm transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover [.missing_&]:opacity-70 [.missing_&_.hist-dir-label]:text-muted [.missing_&_.hist-dir-label]:line-through${d.exists === false ? ' missing' : ''}\">\n <span class=\"hist-dir-label flex-1 font-mono truncate text-fg text-[12.5px]\" title=\"${escapeHtml(d.projectsDir || '')}\">${escapeHtml(d.label)}</span>\n ${status}\n <button class=\"hist-dir-del w-7 h-7 border-0 rounded-full bg-transparent text-muted cursor-pointer flex items-center justify-center shrink-0 transition-colors duration-200 hover:enabled:text-red hover:enabled:bg-red-soft disabled:opacity-25 disabled:cursor-not-allowed\" data-del-dir=\"${escapeHtml(d.id)}\" title=\"${escapeHtml(I18n.t('providers.remove'))}\"${d.id === '~/.claude' ? ' disabled' : ''}>${I.trash || '⌫'}</button>\n </div>`;\n }).join('') || `<div class=\"caption text-caption text-xs\">${escapeHtml(I18n.t('settings.none'))}</div>`;\n}\nasync function addHistDirPath(v) {\n v = (v || '').trim();\n if (!v) return;\n const dirs = (config.historyDirs || []).slice();\n if (!dirs.includes(v)) dirs.push(v);\n await persist({ historyDirs: dirs });\n}\nasync function pickHistDir() {\n if (!api.historyPickDir) return;\n let res; try { res = await api.historyPickDir(); } catch (_) { return; }\n if (!res || res.canceled || !res.path) return;\n await addHistDirPath(res.path);\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag text-[11px] font-mono bg-chip-bg rounded-[4px] px-1.5 py-0.25 text-fg whitespace-nowrap\">${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map text-[11px] font-mono bg-brand-soft rounded-[4px] px-1.5 py-0.25 text-brand font-medium whitespace-nowrap\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n <span class=\"grip text-caption cursor-grab text-[12px] opacity-30 leading-none select-none group-hover:opacity-65 transition-opacity duration-150\" title=\"${escapeHtml(I18n.t('providers.reorder'))}\">⠿</span>\n <div class=\"prov-icon w-9 h-9 rounded-[9px] shrink-0 flex items-center justify-center text-white font-bold text-[13px] tracking-tight shadow-sm\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo min-w-0\">\n <div class=\"pname flex items-center gap-1.5 font-semibold text-[14.5px] tracking-tight text-fg\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active text-[10.5px] font-semibold text-green bg-green-soft rounded-full px-1.75 py-0.25\">' + escapeHtml(I18n.t('providers.active')) + '</span>' : ''}</div>\n <div class=\"pmeta mt-0.5 text-xs font-mono text-caption truncate\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels flex gap-1 flex-wrap justify-end max-w-[200px]\">${tags.join('') || '<span class=\"caption text-caption text-xs\">—</span>'}</div>\n <div class=\"pactions flex gap-0.25\">\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.test'))}\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-chip-bg hover:text-fg\" title=\"${escapeHtml(I18n.t('providers.edit'))}\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button class=\"w-6.5 h-6.5 border-0 rounded-[6px] bg-transparent text-muted cursor-pointer flex items-center justify-center transition-all duration-100 hover:bg-red-soft hover:text-red danger\" title=\"${escapeHtml(I18n.t('providers.delete'))}\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected');\n const dot = $('mStatus').querySelector('.pulse-dot, .live-dot');\n if (dot) {\n const isLive = !!(status.connected || status.running);\n dot.classList.toggle('on', isLive);\n dot.classList.toggle('off', !isLive);\n }\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService');\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone');\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone');\n renderGwLogStatus();\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = fmtTime();\n renderMonitor();\n $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total });\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); }\n const agentTag = r.agentId ? `<span class=\"agent-tag sub text-[9px] font-bold px-1 py-0.25 rounded-[4px] leading-[1.2] shrink-0 text-muted bg-chip-bg border border-border-custom\" title=\"${escapeHtml(I18n.t('monitor.subReq'))}\">sub</span>` : '';\n row.innerHTML = `\n <span class=\"sdot w-1.5 h-1.5 rounded-full shrink-0 [&.ok]:bg-green [&.err]:bg-red ${okCls}\"></span>\n <span class=\"method font-mono text-[10px] text-caption w-10\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models flex-1 min-w-0 font-mono text-[11px] flex items-center gap-1.25 overflow-hidden\">\n <span class=\"req text-fg truncate\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow text-brand opacity-55\">→</span>\n <span class=\"out text-muted truncate\" title=\"${escapeHtml(I18n.t('monitor.upstreamModel'))}\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? `<span class=\"rewrite text-brand text-[10px]\" title=\"${escapeHtml(I18n.t('monitor.rewriteTitle'))}\">✎</span>` : ''}\n </span>\n <span class=\"prov text-caption text-[11px] max-w-[100px] truncate\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code font-mono font-semibold w-8.5 text-right [&.ok]:text-green [&.err]:text-red ${okCls}\">${r.status}</span>\n <span class=\"ms font-mono text-caption w-12 text-right\">${r.ms}ms</span>\n <span class=\"ts font-mono text-caption text-[10px] w-14 text-right\">${fmtTime()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n scheduleHeroUsage();\n}\n/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */\nconst gwLog = { seen: new Set(), items: [] };\n\n// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer\n// notices (provider test, save error, …) have none and are always appended.\nfunction addGatewayLog(l) {\n if (!l) return false;\n if (l.seq != null) {\n if (gwLog.seen.has(l.seq)) return false;\n gwLog.seen.add(l.seq);\n }\n if (l.ts == null) l.ts = Date.now();\n gwLog.items.push(l);\n while (gwLog.items.length > 100) gwLog.items.shift();\n return true;\n}\n\nfunction renderGwLogStatus() {\n const el = $('gwLogStatus');\n if (!el) return;\n const running = !!(status.connected || status.running);\n const port = (status.running && status.port) || config.port;\n el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off');\n el.innerHTML = `<span class=\"rl-dot\"></span>${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`;\n}\n\nfunction renderGatewayLog() {\n const el = $('rawLog');\n if (!el) return;\n if (!gwLog.items.length) {\n el.innerHTML = `<div class=\"raw-log-empty\">${escapeHtml(I18n.t('monitor.logEmpty'))}</div>`;\n return;\n }\n const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse();\n el.innerHTML = rows.map((l) => {\n const lv = String(l.level || 'info');\n const t = fmtTime(l.ts);\n return `<div class=\"raw-log-line lv-${escapeHtml(lv)}\"><span class=\"rl-lv\">${escapeHtml(lv)}</span><span class=\"rl-msg\">${escapeHtml(l.msg || '')}</span><span class=\"rl-t\">${escapeHtml(t)}</span></div>`;\n }).join('');\n}\n\nfunction pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); }\n\n// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner.\nasync function refreshGatewayLog() {\n if (api.logsGet) {\n try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {}\n }\n renderGwLogStatus();\n renderGatewayLog();\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyText(cap) {\n if (!cap || !cap.text) return { text: '', lang: 'plaintext' };\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n return { text, lang };\n}\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.empty'))}</div>`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `<div class=\"dr-trunc text-[11.5px] text-amber mb-1.5\">${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}</div>` : '';\n return note + `<pre class=\"dr-pre bg-[#f6f8fa] dark:bg-[#0c0e12] text-[#24292e] dark:text-[#e8edf4] border border-border-custom dark:border-white/8 rounded-sm p-3 overflow-x-auto text-xs leading-[1.55] max-h-[62vh]\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\n// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches.\nconst DR_MARK_CAP = 800;\nlet drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1;\nfunction drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; }\nfunction updateDrCount() {\n const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count');\n if (!c) return;\n const shown = Math.min(drMatches.length, DR_MARK_CAP);\n c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0';\n}\nfunction applyDrSearch(q) {\n const code = drCodeEl();\n if (!code) return;\n q = q || '';\n if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; }\n const hay = drBodyText.toLowerCase(), needle = q.toLowerCase();\n drMatches = [];\n for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i);\n if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; }\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n let html = '', last = 0;\n for (let k = 0; k < n; k++) {\n const pos = drMatches[k];\n html += escapeHtml(drBodyText.slice(last, pos)) + '<mark class=\"dr-mark\">' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '</mark>';\n last = pos + q.length;\n }\n html += escapeHtml(drBodyText.slice(last));\n code.innerHTML = html;\n drMatchIdx = 0; drHighlightCurrent(); updateDrCount();\n}\nfunction drHighlightCurrent() {\n const code = drCodeEl();\n if (!code) return;\n const marks = code.querySelectorAll('.dr-mark');\n marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx));\n if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' });\n}\nfunction drNavSearch(dir) {\n const n = Math.min(drMatches.length, DR_MARK_CAP);\n if (!n) return;\n drMatchIdx = (drMatchIdx + dir + n) % n;\n drHighlightCurrent(); updateDrCount();\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.none'))}</div>`;\n return '<div class=\"dr-kv border border-border-custom rounded-sm overflow-hidden\">' + keys.map((k) => `<div class=\"dr-kv-row flex gap-2.5 py-1.5 px-2.5 font-mono text-xs odd:bg-transparent even:bg-chip-bg\"><span class=\"dr-k text-brand shrink-0 min-w-[150px] break-all\">${escapeHtml(k)}</span><span class=\"dr-v text-fg break-all\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy');\n const headTitle = isReq\n ? `${escapeHtml(I18n.t('drawer.reqHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} <span class=\"dr-sub font-medium font-mono text-caption normal-case tracking-normal truncate\">HTTP ${escapeHtml(d.status || '')}</span>`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `<span class=\"flex-1\"></span><div class=\"dr-search-wrap flex items-center gap-1 normal-case tracking-normal\">\n <input class=\"dr-search w-[140px] bg-bg-input border border-border-custom rounded-md px-2 py-1 text-[11px] font-normal text-fg outline-none focus:border-primary\" type=\"text\" placeholder=\"${escapeHtml(I18n.t('drawer.searchBody'))}\" />\n <span class=\"dr-search-count text-caption text-[10px] font-normal tabular-nums min-w-[36px] text-center\">0/0</span>\n <button class=\"dr-search-prev tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchPrev'))}\">↑</button>\n <button class=\"dr-search-next tool-btn w-[22px] h-[22px] border border-border-custom rounded-[5px] bg-bg-elev text-muted cursor-pointer inline-flex items-center justify-center text-[11px] hover:text-fg hover:bg-chip-bg hover:border-border-strong\" title=\"${escapeHtml(I18n.t('drawer.searchNext'))}\">↓</button>\n </div>` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title flex items-center gap-2 text-xs font-bold text-fg my-4 mt-4 mb-2 uppercase tracking-wide\">${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}<button class=\"btn btn-sm dr-copy ${copyCls}bg-bg-elev text-fg border border-border-custom rounded-[6px] px-2.25 py-1 text-[11px] font-medium normal-case tracking-normal cursor-pointer transition-all duration-150 hover:bg-chip-bg hover:border-border-strong active:scale-[0.98]\" data-copy-body=\"${which}\" title=\"${escapeHtml(I18n.t('drawer.copy'))}\">${escapeHtml(copyLabel)}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n const codeEl = drCodeEl();\n drBodyHTML = codeEl ? codeEl.innerHTML : '';\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = '';\n drStatus.classList.remove('ok', 'err');\n }\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = `<div class=\"dr-empty p-2.5 text-caption text-[12.5px]\">${escapeHtml(I18n.t('drawer.expired'))}</div>`;\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n const drStatus = $('drStatus');\n if (drStatus) {\n drStatus.textContent = d.status != null ? d.status : '—';\n drStatus.classList.toggle('ok', ok);\n drStatus.classList.toggle('err', !ok);\n }\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` <span class=\"rewrite\" title=\"${escapeHtml(I18n.t('drawer.rewritten'))}\">✎</span>` : ''}`;\n const meta = [\n [I18n.t('drawer.service'), d.provider],\n [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''],\n [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null,\n [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''],\n d.error ? [I18n.t('drawer.error'), d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip text-[11.5px] px-2.25 py-[2.5px] rounded-full bg-chip-bg text-fg\"><span class=\"muted text-muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n modalIcon = null; // a preset uses its brand logo\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const el = $('fIconPreview');\n const iconData = renderProviderIcon($('fName').value || '?', modalIcon);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction resizeImage(file, size) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const img = new Image();\n img.onload = () => {\n try {\n const c = document.createElement('canvas'); c.width = size; c.height = size;\n const ctx = c.getContext('2d');\n const s = Math.min(img.width, img.height);\n ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);\n resolve(c.toDataURL('image/png'));\n } catch (e) { reject(e); }\n };\n img.onerror = reject;\n img.src = reader.result;\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\nfunction openIconPicker(anchor) {\n const existing = document.querySelector('.icon-picker');\n if (existing) { existing.remove(); return; }\n const pop = document.createElement('div');\n pop.className = 'icon-picker';\n pop.innerHTML =\n `<div class=\"ip-grid\">${ICON_EMOJIS.map((e) => `<button type=\"button\" class=\"ip-emoji\" data-emoji=\"${escapeHtml(e)}\">${escapeHtml(e)}</button>`).join('')}</div>` +\n `<div class=\"ip-actions\"><button type=\"button\" class=\"ip-act\" data-act=\"upload\">${escapeHtml(I18n.t('modal.iconUpload'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"random\">${escapeHtml(I18n.t('modal.iconRandom'))}</button><button type=\"button\" class=\"ip-act\" data-act=\"reset\">${escapeHtml(I18n.t('modal.iconReset'))}</button></div>` +\n `<input type=\"file\" accept=\"image/*\" class=\"ip-file\" hidden />`;\n document.body.appendChild(pop);\n const r = anchor.getBoundingClientRect();\n let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10));\n let y = Math.round(r.bottom + 8);\n if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8));\n pop.style.left = x + 'px'; pop.style.top = y + 'px';\n const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };\n const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); };\n const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };\n setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0);\n pop.addEventListener('click', (e) => {\n const em = e.target.closest('.ip-emoji');\n if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; }\n const act = e.target.closest('.ip-act');\n if (!act) return;\n if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); }\n else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); }\n else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); }\n });\n pop.querySelector('.ip-file').addEventListener('change', (e) => {\n const f = e.target.files && e.target.files[0];\n if (!f) return;\n resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close());\n });\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row flex items-center gap-1.75';\n const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary';\n row.innerHTML = `\n <input class=\"m-alias ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.aliasPlaceholder'))}\" />\n <span class=\"map-arrow text-caption shrink-0\">→</span>\n <input class=\"m-upstream ${mapInputCls}\" placeholder=\"${escapeHtml(I18n.t('modal.upstreamPlaceholder'))}\" />\n <button class=\"icon-btn m-del w-6 h-6 p-0 shrink-0 flex items-center justify-center border-0 rounded-md bg-transparent text-muted cursor-pointer transition-colors duration-140 hover:bg-red-soft hover:text-red\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n modalIcon = provider ? (provider.icon || null) : null;\n $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle');\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show');\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n const tr = $('testResult');\n if (tr) {\n tr.classList.add('hidden');\n tr.classList.remove('ok', 'err', 'pending');\n }\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || I18n.t('providers.unnamed'),\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (modalIcon) p.icon = modalIcon;\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n // Reconcile the boot language (from localStorage) with the persisted config truth.\n try {\n if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); }\n else localStorage.setItem('clawdy-lang', I18n.lang);\n } catch (_) {}\n renderAll();\n refreshGatewayLog();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? I18n.t('hero.disconnecting') : I18n.t('hero.connecting');\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || I18n.t('err.opFailed'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = I18n.t('copy.copiedCheck');\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\n// Lightweight styled confirm dialog → Promise<boolean>. For actions that are easy to mis-trigger.\nfunction confirmDialog({ title, message, confirmText, cancelText, danger }) {\n return new Promise((resolve) => {\n const ov = document.createElement('div');\n ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md';\n ov.innerHTML = `<div class=\"bg-bg-elev border border-border-custom rounded-[14px] shadow-card-hover w-[348px] max-w-[88vw] p-5 flex flex-col gap-2.5\" style=\"animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1)\">\n <h3 class=\"text-[14px] font-semibold text-fg tracking-tight\">${escapeHtml(title || '')}</h3>\n <p class=\"text-[12.5px] text-caption leading-[1.55]\">${escapeHtml(message || '')}</p>\n <div class=\"flex justify-end gap-2 mt-2\">\n <button class=\"cd-cancel bg-bg-elev text-fg border border-border-custom rounded-md px-3.5 py-1.5 text-[12px] font-medium cursor-pointer transition-all duration-140 hover:bg-chip-bg hover:border-border-strong active:scale-[0.97]\">${escapeHtml(cancelText || I18n.t('modal.cancel'))}</button>\n <button class=\"cd-ok border-none rounded-md px-3.5 py-1.5 text-[12px] font-semibold text-white cursor-pointer transition-all duration-140 active:scale-[0.97] ${danger ? 'bg-red' : 'bg-primary hover:bg-primary-hover'}\">${escapeHtml(confirmText || I18n.t('modal.cancel'))}</button>\n </div>\n </div>`;\n document.body.appendChild(ov);\n const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); };\n const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } };\n ov.querySelector('.cd-ok').addEventListener('click', () => done(true));\n ov.querySelector('.cd-cancel').addEventListener('click', () => done(false));\n ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });\n document.addEventListener('keydown', onKey);\n setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0);\n });\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'clawdy_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n void target.offsetWidth;\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => {\n if (target) target.style.transition = '';\n }, 280);\n }\n\n if (view === 'conversations' && window.ClawdyConversations) window.ClawdyConversations.onShow();\n if (view === 'monitor') refreshGatewayLog();\n if (view === 'settings') startDesktopPoll(); else stopDesktopPoll();\n // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere.\n if (api.setSettingsMode) api.setSettingsMode(view === 'settings');\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('clawdy-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\n// Settings sub-nav: keep the main panel focused on one section at a time.\nfunction switchSettings(pane) {\n const nav = $('settingsNav');\n if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane));\n const panes = $('settingsPanes');\n if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane));\n // Refresh the live cards the moment their section is revealed.\n if (pane === 'desktop') renderDesktopCard();\n if (pane === 'privacy') { renderPresidioCard(); renderPresidioLog(); refreshFindCount(); if (presidioTab === 'findings') renderPresidioFindings(); }\n}\n\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n const settingsNav = $('settingsNav');\n if (settingsNav) settingsNav.addEventListener('click', (e) => {\n const b = e.target.closest('.settings-subnav-item');\n if (b && b.dataset.settings) switchSettings(b.dataset.settings);\n });\n // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar.\n const subnavBtn = $('btnSubnavCollapse');\n if (settingsNav && subnavBtn) {\n try {\n if (localStorage.getItem('clawdy-subnav-collapsed') === '1') {\n settingsNav.classList.add('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic && I.chevronRight) ic.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n subnavBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n const collapsed = settingsNav.classList.toggle('collapsed');\n const ic = subnavBtn.querySelector('[data-icon]');\n if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('clawdy-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('clawdy-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('clawdy-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n const heroRanges = $('heroRanges');\n if (heroRanges) heroRanges.addEventListener('click', (e) => {\n const b = e.target.closest('[data-hrange]');\n if (!b) return;\n heroRange = b.dataset.hrange;\n heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b));\n renderHeroUsage();\n });\n const heroEndpoint = $('heroEndpoint');\n if (heroEndpoint) heroEndpoint.addEventListener('click', () => {\n const port = (status.running && status.port) || config.port;\n if (api.copy) api.copy(`http://localhost:${port}`);\n const t = $('heroEndpointText');\n if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); }\n });\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n if ($('fLang')) $('fLang').addEventListener('change', async (e) => {\n const language = e.target.value;\n I18n.setLang(language); // updates <html lang> + localStorage['clawdy-lang']\n I18n.apply(document); // static data-i18n nodes\n renderAll(); // dynamic strings (hero/status/monitor/providers/settings)\n if (window.ClawdyConversations && window.ClawdyConversations.setLang) window.ClawdyConversations.setLang();\n await persist({ language }); // → config:save → main rebuilds tray on next open\n });\n\n // History directories — primary action opens a native picker (hidden dirs shown)\n const btnPickHistDir = $('btnPickHistDir');\n if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir);\n // (Manual text input removed in favor of native dialog picker)\n const histDirList = $('histDirList');\n if (histDirList) histDirList.addEventListener('click', async (e) => {\n const btn = e.target.closest('[data-del-dir]');\n if (!btn || btn.disabled) return;\n const id = btn.dataset.delDir;\n if (id === '~/.claude') return;\n const dirs = (config.historyDirs || []).filter((d) => d !== id);\n await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] });\n });\n\n const btnDesktop = $('btnDesktopConnect');\n if (btnDesktop) btnDesktop.addEventListener('click', async () => {\n const note = $('desktopNote');\n const wasConnected = btnDesktop.dataset.connected === '1';\n btnDesktop.disabled = true;\n btnDesktop.textContent = I18n.t(wasConnected ? 'settings.desktopRestoring' : 'settings.desktopConnecting');\n let res;\n try { res = wasConnected ? await api.desktopDisconnect() : await api.desktopConnect(); }\n catch (e) { res = { ok: false, message: (e && e.message) || '' }; }\n if (note) {\n note.dataset.transient = '1';\n if (wasConnected) {\n if (res && res.cancelled) { delete note.dataset.transient; }\n else note.textContent = I18n.t(res && res.removed ? 'settings.desktopRestored' : 'settings.desktopRestoreSteps');\n } else if (res && res.ok) {\n note.textContent = I18n.t('settings.desktopSteps');\n } else {\n const reason = res && res.reason;\n note.textContent = reason === 'noProvider' ? I18n.t('settings.desktopNoProvider')\n : reason === 'notInstalled' ? I18n.t('settings.desktopNotInstalled')\n : (res && res.message) || I18n.t('err.opFailed');\n delete note.dataset.transient;\n }\n }\n btnDesktop.disabled = false;\n setTimeout(renderDesktopCard, 1800);\n });\n const btnDesktopRefresh = $('btnDesktopRefresh');\n if (btnDesktopRefresh) btnDesktopRefresh.addEventListener('click', () => {\n const n = $('desktopNote');\n if (n) delete n.dataset.transient; // show the true current state, not a lingering action note\n renderDesktopCard();\n });\n // Re-check the moment the window regains focus (e.g. right after approving in System Settings),\n // in case the background poll was throttled while Clawdy was in the background.\n window.addEventListener('focus', () => { if (desktopPollTimer) { renderDesktopCard(); renderPresidioCard(); } });\n\n const fPresidio = $('fPresidio');\n if (fPresidio) fPresidio.addEventListener('change', async (e) => {\n const on = e.target.checked;\n const note = $('presidioNote');\n config.presidio = Object.assign({}, config.presidio, { enabled: on });\n if (note) {\n if (on) { note.dataset.transient = '1'; note.textContent = I18n.t('settings.presidioInstallingNote'); }\n else delete note.dataset.transient;\n }\n let res;\n try { res = await api.presidioEnable(on); } catch (_) { res = null; }\n if (note && on) {\n if (res && res.reason === 'installing') note.textContent = I18n.t('settings.presidioInstallingNote');\n else if (res && res.ok) note.textContent = I18n.t('settings.presidioRunningNote');\n else if (res && res.reason === 'missing-source') note.textContent = I18n.t('settings.presidioNoSource');\n else note.textContent = I18n.t('settings.presidioStartFail');\n }\n renderPresidioCard();\n });\n // Tier sub-options are config-only (no service restart) — persist and let the proxy read them.\n const persistPresidio = (patch) => persist({ presidio: Object.assign({}, config.presidio, patch) });\n const fPresidioNer = $('fPresidioNer');\n if (fPresidioNer) fPresidioNer.addEventListener('change', (e) => persistPresidio({ ner: e.target.checked }));\n const fPresidioLlm = $('fPresidioLlm');\n if (fPresidioLlm) fPresidioLlm.addEventListener('change', (e) => {\n const on = e.target.checked;\n const ollama = $('fOllamaUrl');\n if (ollama) { ollama.classList.toggle('hidden', !on); if (on && !ollama.value) ollama.value = 'http://localhost:11434'; }\n persistPresidio({ llm: on, ollamaUrl: (ollama && ollama.value) || 'http://localhost:11434' });\n });\n const fOllamaUrl = $('fOllamaUrl');\n if (fOllamaUrl) fOllamaUrl.addEventListener('change', (e) => persistPresidio({ ollamaUrl: e.target.value.trim() }));\n const fPresidioDeid = $('fPresidioDeid');\n if (fPresidioDeid) fPresidioDeid.addEventListener('change', (e) => persistPresidio({ deidentify: e.target.value }));\n const fPresidioThreshold = $('fPresidioThreshold');\n if (fPresidioThreshold) {\n fPresidioThreshold.addEventListener('input', (e) => { const el = $('fPresidioThresholdVal'); if (el) el.textContent = parseFloat(e.target.value).toFixed(2); });\n fPresidioThreshold.addEventListener('change', (e) => persistPresidio({ threshold: parseFloat(e.target.value) }));\n }\n const fPresidioNerModel = $('fPresidioNerModel');\n if (fPresidioNerModel) fPresidioNerModel.addEventListener('change', (e) => persistPresidio({ nerModel: e.target.value }));\n // Presidio console tabs + live append/findings + clear.\n const pTabs = $('presidioConsoleTabs');\n if (pTabs) pTabs.addEventListener('click', (e) => { const b = e.target.closest('.ptab'); if (b) setPresidioTab(b.dataset.ptab); });\n if (api.onPresidioLog) api.onPresidioLog(appendPresidioLog);\n if (api.onPresidioFinding) api.onPresidioFinding(appendFinding);\n const btnPresidioLogClear = $('btnPresidioLogClear');\n if (btnPresidioLogClear) btnPresidioLogClear.addEventListener('click', () => {\n if (presidioTab === 'findings') {\n if (api.presidioFindingsClear) api.presidioFindingsClear().catch(() => {});\n const h = $('presidioFindings'); if (h) h.innerHTML = findingsTableHTML([]);\n updateFindCount(0);\n } else {\n if (api.presidioLogsClear) api.presidioLogsClear().catch(() => {});\n const pre = $('presidioLog'); if (pre) pre.textContent = '';\n }\n });\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm(I18n.t('providers.confirmDelete'))) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (!card || !card.dataset.id) return;\n if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch\n // While the gateway is running, switching is easy to mis-trigger and would re-point every new\n // Claude Code session — so confirm first.\n if (status.connected) {\n const p = config.providers.find((pp) => pp.id === card.dataset.id);\n const ok = await confirmDialog({\n title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }),\n message: I18n.t('switch.confirmMsg'),\n confirmText: I18n.t('switch.confirmOk'),\n });\n if (!ok) return;\n }\n config = await api.setActive(card.dataset.id);\n renderAll();\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n const fIconPreview = $('fIconPreview');\n if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview));\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show');\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.fillUrl');\n tr.classList.remove('hidden', 'ok', 'pending');\n tr.classList.add('err');\n }\n return;\n }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult');\n if (tr) {\n tr.textContent = I18n.t('modal.testing');\n tr.classList.remove('hidden', 'ok', 'err');\n tr.classList.add('pending');\n const res = await api.testProvider(collectProvider());\n tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n tr.classList.remove('pending', 'ok', 'err');\n tr.classList.add(res.ok ? 'ok' : 'err');\n }\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = `<div class=\"state-inline p-4 text-center text-[11.5px] text-caption\">${escapeHtml(I18n.t('monitor.streamEmpty'))}</div>`;\n gwLog.items.length = 0; gwLog.seen.clear();\n renderGatewayLog();\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = I18n.t('monitor.waitingDots');\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n if (api.logsClear) api.logsClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) {\n reqDrawerBody.addEventListener('click', (e) => {\n if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; }\n if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; }\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200);\n }\n });\n let _drSearchT = null;\n reqDrawerBody.addEventListener('input', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n const v = e.target.value;\n clearTimeout(_drSearchT);\n _drSearchT = setTimeout(() => applyDrSearch(v), 110);\n });\n reqDrawerBody.addEventListener('keydown', (e) => {\n if (!e.target.classList || !e.target.classList.contains('dr-search')) return;\n if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); }\n else if (e.key === 'Escape') {\n if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); }\n else e.target.blur();\n }\n });\n }\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('clawdy-theme') || 'light'); } catch (_) { applyTheme('light'); }\n// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from\n// localStorage; the async refresh() then reconciles with config.language (the source of truth).\nfunction bootLang() {\n let l = '';\n try { l = localStorage.getItem('clawdy-lang') || ''; } catch (_) {}\n if (!l) {\n const nav = (navigator.language || 'en').toLowerCase();\n l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh')\n : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en';\n }\n try { I18n.setLang(l); I18n.apply(document); } catch (_) {}\n}\nbootLang();\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "ae82c779bbaf526d3ae9ac1ff38abb48ccb1135c": {"t": 1785766855.8631856, "n": 31107, "h": "b7b0a6c692c01f73", "c": "'use strict';\n\nconst api = window.clawdy;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\nconst I = window.ClawdyIcons || {};\n\nfunction injectIcons(root) {\n (root || document).querySelectorAll('[data-icon]').forEach((el) => {\n const name = el.dataset.icon;\n if (I[name]) el.innerHTML = I[name];\n });\n}\n\nconst PRESETS = {\n glm: { name: '智谱 GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.1', smallFastModel: 'glm-5.1' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-chat', smallFastModel: 'deepseek-chat' },\n mimo: { name: '小米 MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: '', smallFastModel: '' },\n kimi: { name: '月之暗面 Kimi', baseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'kimi-k2-0905-preview', smallFastModel: 'kimi-k2-0905-preview' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: '智谱 GLM', deepseek: 'DeepSeek', mimo: '小米 MiMo', kimi: '月之暗面 Kimi', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\nfunction renderProviderIcon(name) {\n const n = (name || '').trim().toLowerCase();\n\n if (n.includes('kimi') || n.includes('moonshot') || n.includes('月之')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: `<img src=\"assets/kimi.svg\" class=\"prov-svg\" style=\"width: 100%; height: 100%; display: block;\" />`\n };\n }\n if (n.includes('deepseek')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: `<img src=\"assets/deepseek.svg\" class=\"prov-svg\" style=\"width: 100%; height: 100%; display: block;\" />`\n };\n }\n if (n.includes('glm') || n.includes('智谱') || n.includes('bigmodel')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: `<img src=\"assets/zhipu.svg\" class=\"prov-svg\" style=\"width: 100%; height: 100%; display: block;\" />`\n };\n }\n if (n.includes('mimo') || n.includes('小米') || n.includes('xiaomi')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: `<img src=\"assets/xiaomi.svg\" class=\"prov-svg\" style=\"width: 100%; height: 100%; display: block;\" />`\n };\n }\n if (n.includes('zenmux')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: `<img src=\"assets/zenmux.svg\" class=\"prov-svg\" style=\"width: 100%; height: 100%; display: block;\" />`\n };\n }\n\n let svgContent = '';\n let themeColor = 'hsl(215, 100%, 60%)'; // default blue\n\n if (n.includes('claude') || n.includes('anthropic')) {\n themeColor = 'hsl(28, 70%, 48%)'; // Anthropic Bronze\n svgContent = `<path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z M12 2v20 M3 12h18\" stroke-linecap=\"round\"/>`;\n } else {\n // Custom generic high-tech cube icon\n const h = hashHue(name || '?');\n themeColor = `hsl(${h}, 70%, 52%)`;\n svgContent = `<path d=\"M12 2L3 7v10l9 5 9-5V7l-9-5z\" stroke-linecap=\"round\"/><path d=\"M12 22V12M12 12L3 7M12 12l9-5\" stroke-linecap=\"round\"/>`;\n }\n\n const h = hashHue(name || '?');\n const gradient = `background: linear-gradient(135deg, ${themeColor}, hsl(${(h + 40) % 360}, 75%, 45%))`;\n \n return {\n style: gradient,\n html: `<svg class=\"prov-svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">${svgContent}</svg>`\n };\n}\nfunction mask(t) { return !t ? '未填密钥' : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n n.textContent = text;\n n.className = 'hero-hint' + (warn ? ' warn' : '');\n}\nfunction hideHeroNote() { $('heroNote').className = 'hero-hint hidden'; }\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n $('heroIcon').innerHTML = I.connected || '';\n $('heroTitle').textContent = ap ? ap.name : '已接入';\n $('heroSub').innerHTML = ap ? `经 <b>${escapeHtml(ap.name)}</b> 转发 · 点卡片切换` : '网关运行中';\n $('btnConnect').textContent = '断开';\n showHeroNote('保持运行。切换服务后重启 Claude Code 新会话生效。', false);\n } else {\n hero.classList.remove('connected');\n $('heroIcon').innerHTML = I.connect || '';\n $('heroTitle').textContent = '未接入';\n $('heroSub').textContent = '选择服务,一键写入 Claude Code 配置';\n $('btnConnect').textContent = '一键接入';\n hideHeroNote();\n }\n}\n\nfunction renderStatus() {\n const chip = $('statusPill');\n if (status.connected) {\n chip.className = 'status-chip on';\n chip.innerHTML = '<span class=\"status-dot\"></span><span class=\"status-text\">已接入</span>';\n $('brandTitle').classList.add('running');\n } else {\n chip.className = 'status-chip';\n chip.innerHTML = '<span class=\"status-dot\"></span><span class=\"status-text\">未接入</span>';\n $('brandTitle').classList.remove('running');\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://localhost:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'clawdy-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://localhost:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n '# 一般无需手动设置,点上方「一键接入」即可',\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? 'Claude 配置:' + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n const se = $('startError');\n if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider' + (isActive ? ' active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag\">主 ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag\">快 ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n\n const iconData = renderProviderIcon(p.name);\n el.innerHTML = `\n <span class=\"grip\" title=\"拖动排序\">⠿</span>\n <div class=\"prov-icon\" style=\"${iconData.style}\">${iconData.html}</div>\n <div class=\"pinfo\">\n <div class=\"pname\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active\">活跃</span>' : ''}</div>\n <div class=\"pmeta\">${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}</div>\n </div>\n <div class=\"pmodels\">${tags.join('') || '<span class=\"caption\">—</span>'}</div>\n <div class=\"pactions\">\n <button title=\"测试\" data-test=\"${p.id}\">${I.refresh || '↻'}</button>\n <button title=\"编辑\" data-edit=\"${p.id}\">${I.edit || '✎'}</button>\n <button title=\"删除\" class=\"danger\" data-del=\"${p.id}\">${I.trash || '⌫'}</button>\n </div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? '已接入' : status.running ? '运行中' : '未接入';\n $('mStatus').querySelector('.pulse-dot, .live-dot').className = 'pulse-dot ' + (status.connected || status.running ? 'on' : 'off');\n $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : '未选择服务';\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? `成功率 ${Math.round((stats.ok / stats.total) * 100)}%` : '成功率 —';\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? `最近 ${stats.last}` : '最近 —';\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = new Date().toLocaleTimeString();\n renderMonitor();\n $('streamHint').textContent = `已转发 ${stats.total} 次`;\n const list = $('streamList');\n const empty = list.querySelector('.state-inline, .empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row';\n if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = '点击查看完整请求 / 响应'; }\n const agentTag = r.agentId ? '<span class=\"agent-tag sub\" title=\"子代理请求\">子</span>' : '';\n row.innerHTML = `\n <span class=\"sdot ${okCls}\"></span>\n <span class=\"method\">${escapeHtml(r.method || '')}</span>\n ${agentTag}\n <span class=\"models\">\n <span class=\"req\" title=\"HTTP body.model\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow\">→</span>\n <span class=\"out\" title=\"上游实际模型\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? '<span class=\"rewrite\" title=\"响应 model 已改回客户端名称\">✎</span>' : ''}\n </span>\n <span class=\"prov\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code ${okCls}\">${r.status}</span>\n <span class=\"ms\">${r.ms}ms</span>\n <span class=\"ts\">${new Date().toLocaleTimeString()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n}\nfunction pushRawLog(l) {\n const el = $('rawLog');\n const line = document.createElement('div');\n line.textContent = `[${new Date().toLocaleTimeString()}] ${l.level || 'info'} — ${l.msg}`;\n el.insertBefore(line, el.firstChild);\n while (el.children.length > 120) el.removeChild(el.lastChild);\n}\n\n/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */\nlet reqDrawerTab = 'req';\nlet reqDrawerData = null;\n\nfunction fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; }\nfunction prettyBody(cap) {\n if (!cap || !cap.text) return '<div class=\"dr-empty\">(空)</div>';\n let text = cap.text, lang = 'plaintext';\n const trimmed = text.trim();\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {}\n }\n const note = cap.truncated ? `<div class=\"dr-trunc\">仅显示前 ${fmtBytes(cap.bytes - cap.truncated)} / 共 ${fmtBytes(cap.bytes)}(已截断)</div>` : '';\n return note + `<pre class=\"dr-pre\"><code class=\"language-${lang}\">${escapeHtml(text)}</code></pre>`;\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return '<div class=\"dr-empty\">(无)</div>';\n return '<div class=\"dr-kv\">' + keys.map((k) => `<div class=\"dr-kv-row\"><span class=\"dr-k\">${escapeHtml(k)}</span><span class=\"dr-v\">${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}</span></div>`).join('') + '</div>';\n}\nfunction renderReqDrawerBody() {\n const d = reqDrawerData;\n if (!d) return;\n const body = $('reqDrawerBody');\n const isReq = reqDrawerTab === 'req';\n const headers = isReq ? d.reqHeaders : d.resHeaders;\n const cap = isReq ? d.reqBody : d.resBody;\n const which = isReq ? 'req' : 'res';\n const copyLabel = cap && cap.truncated ? '复制(部分)' : '复制';\n const headTitle = isReq\n ? `请求头 <span class=\"dr-sub\">${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}</span>`\n : `响应头 <span class=\"dr-sub\">HTTP ${escapeHtml(d.status || '')}</span>`;\n body.innerHTML = `<div class=\"dr-section-title\">${headTitle}</div>${kvTable(headers)}<div class=\"dr-section-title\">${isReq ? '请求体' : '响应体'} <button class=\"btn btn-sm dr-copy\" data-copy-body=\"${which}\" title=\"复制已捕获内容\">${copyLabel}</button></div>${prettyBody(cap)}`;\n // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI.\n body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} });\n}\nasync function openReqDetail(id) {\n let d = null;\n try { d = await api.monitorGet(id); } catch (_) {}\n if (!d) {\n // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer.\n reqDrawerData = null;\n $('drMethod').textContent = '—';\n $('drStatus').textContent = ''; $('drStatus').className = 'dr-status';\n $('drModel').textContent = '';\n $('reqMeta').innerHTML = '';\n $('reqDrawerBody').innerHTML = '<div class=\"dr-empty\">该请求的详情已不可用(仅保留最近 30 条转发的完整报文)。</div>';\n $('reqDrawer').classList.remove('hidden');\n return;\n }\n reqDrawerData = d; reqDrawerTab = 'req';\n const ok = d.status >= 200 && d.status < 400;\n $('drMethod').textContent = d.method || 'POST';\n $('drStatus').textContent = d.status != null ? d.status : '—';\n $('drStatus').className = 'dr-status ' + (ok ? 'ok' : 'err');\n $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} <span class=\"arrow\">→</span> ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ' <span class=\"rewrite\" title=\"响应已改回客户端名称\">✎</span>' : ''}`;\n const meta = [\n ['服务', d.provider],\n ['耗时', d.ms != null ? d.ms + ' ms' : ''],\n ['会话', d.sessionId ? String(d.sessionId).slice(0, 8) : ''],\n d.agentId ? ['代理', '子代理'] : null,\n ['时间', d.ts ? new Date(d.ts).toLocaleTimeString() : ''],\n d.error ? ['错误', d.error] : null,\n ].filter((r) => r && r[1]);\n $('reqMeta').innerHTML = meta.map((r) => `<span class=\"dr-chip\"><span class=\"muted\">${escapeHtml(r[0])}</span> ${escapeHtml(r[1])}</span>`).join('');\n document.querySelectorAll('.dr-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === reqDrawerTab));\n renderReqDrawerBody();\n $('reqDrawer').classList.remove('hidden');\n}\nfunction closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; }\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip'; b.dataset.preset = key; b.textContent = PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const name = $('fName').value || '?';\n const el = $('fIconPreview');\n const iconData = renderProviderIcon(name);\n el.setAttribute('style', iconData.style);\n el.innerHTML = iconData.html;\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row';\n row.innerHTML = `\n <input class=\"m-alias\" placeholder=\"别名 例如 claude-opus-4.8[1m]\" />\n <span class=\"map-arrow\">→</span>\n <input class=\"m-upstream\" placeholder=\"上游模型 例如 glm-5.2[1m]\" />\n <button class=\"icon-btn m-del\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n $('modalTitle').textContent = provider ? '编辑服务' : '添加服务';\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = '显示';\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into\n const mapDetails = $('mapRows').closest('details');\n if (mapDetails) mapDetails.open = true;\n updateIconPreview();\n $('testResult').className = 'alert hidden';\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || '未命名',\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: '保存失败:' + (e && e.message ? e.message : e) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? '断开中…' : '接入中…';\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote(res.message || '操作失败', true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = '已复制 ✓';\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'clawdy_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n\n // Smooth fade between views\n const viewIds = {\n providers: 'view-providers',\n monitor: 'view-monitor',\n conversations: 'view-conversations',\n settings: 'view-settings',\n };\n const views = Object.values(viewIds).map((id) => $(id));\n\n const current = views.find(el => el && !el.classList.contains('hidden'));\n const targetId = viewIds[view] || 'view-providers';\n const target = $(targetId);\n\n const doSwitch = () => {\n views.forEach(el => {\n if (!el) return;\n const isTarget = el === target;\n el.classList.toggle('hidden', !isTarget);\n if (!isTarget) {\n el.style.transition = '';\n el.style.opacity = '';\n }\n });\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n const emptyAdd = $('btnAddEmpty');\n if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers');\n\n if (target) {\n target.style.transition = 'none';\n target.style.opacity = '0';\n void target.offsetWidth;\n target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)';\n target.style.opacity = '1';\n setTimeout(() => {\n if (target) target.style.transition = '';\n }, 280);\n }\n\n if (view === 'conversations' && window.ClawdyConversations) window.ClawdyConversations.onShow();\n };\n\n if (current && current !== target) {\n current.style.transition = 'opacity 0.12s ease';\n current.style.opacity = '0';\n setTimeout(() => {\n current.style.transition = '';\n current.style.opacity = '';\n doSwitch();\n }, 110);\n } else {\n doSwitch();\n }\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('clawdy-theme', t); } catch (_) {}\n const dark = t === 'dark';\n const hd = document.getElementById('hljs-dark');\n const hl = document.getElementById('hljs-light');\n if (hd) hd.disabled = !dark;\n if (hl) hl.disabled = dark;\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\nfunction bind() {\n if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30);\n injectIcons();\n\n $('tabs').addEventListener('click', (e) => {\n const btn = e.target.closest('.nav-item, .seg-btn');\n if (btn && btn.dataset.view) switchView(btn.dataset.view);\n });\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n\n // Main sidebar collapse (affects all views)\n const sidebar = document.querySelector('.sidebar');\n const collapseBtn = $('btnCollapseSidebar');\n if (collapseBtn && sidebar) {\n // restore\n try {\n if (localStorage.getItem('clawdy-sidebar-collapsed') === '1') {\n sidebar.classList.add('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon && I.chevronRight) icon.innerHTML = I.chevronRight;\n }\n } catch (_) {}\n collapseBtn.addEventListener('click', () => {\n const isCollapsed = sidebar.classList.toggle('collapsed');\n const icon = collapseBtn.querySelector('[data-icon]');\n if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹');\n try { localStorage.setItem('clawdy-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {}\n });\n }\n $('btnConnect').addEventListener('click', toggleConnect);\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: '端口无效:请输入 1–65535 的整数' });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n const btnAddEmpty = $('btnAddEmpty');\n if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n // Resolve the actual button (the click may land on the inner SVG icon).\n const btn = e.target.closest('button');\n if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; }\n if (btn && btn.dataset.del) { if (confirm('确定删除这个服务?')) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } return; }\n if (btn && btn.dataset.test) {\n const p = config.providers.find((pp) => pp.id === btn.dataset.test);\n const orig = btn.innerHTML; // preserve the SVG icon, restore it after\n btn.innerHTML = '…'; btn.disabled = true;\n const res = await api.testProvider(p);\n btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: `测试「${p.name}」: ${res.message}` });\n setTimeout(() => { btn.innerHTML = orig; }, 1800);\n return;\n }\n if (btn) return; // some other button — ignore\n // click anywhere else on the card → set it as the active service\n const card = e.target.closest('.provider');\n if (card && card.dataset.id) { config = await api.setActive(card.dataset.id); renderAll(); }\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? '隐藏' : '显示';\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) { const tr = $('testResult'); tr.className = 'alert err'; tr.textContent = '请填写 API 地址'; return; }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult'); tr.className = 'alert pending'; tr.textContent = '测试中…';\n const res = await api.testProvider(collectProvider());\n tr.className = 'alert ' + (res.ok ? 'ok' : 'err'); tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = '<div class=\"state-inline\">接入网关后,转发记录将实时显示</div>';\n $('rawLog').innerHTML = '';\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = '等待请求…';\n renderMonitor();\n if (api.monitorClear) api.monitorClear();\n closeReqDrawer();\n });\n\n // Request inspector: click a stream row to open its full captured exchange.\n const streamList = $('streamList');\n if (streamList) streamList.addEventListener('click', (e) => {\n const row = e.target.closest('.stream-row');\n if (row && row.dataset.id) openReqDetail(row.dataset.id);\n });\n const rdClose = $('reqDrawerClose');\n if (rdClose) rdClose.addEventListener('click', closeReqDrawer);\n const reqDrawer = $('reqDrawer');\n if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); });\n document.querySelectorAll('.dr-tab').forEach((t) => t.addEventListener('click', () => {\n reqDrawerTab = t.dataset.tab;\n document.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t));\n renderReqDrawerBody();\n }));\n const reqDrawerBody = $('reqDrawerBody');\n if (reqDrawerBody) reqDrawerBody.addEventListener('click', (e) => {\n const cb = e.target.closest('[data-copy-body]');\n if (cb && reqDrawerData) {\n const cap = cb.dataset.copyBody === 'req' ? reqDrawerData.reqBody : reqDrawerData.resBody;\n api.copy((cap && cap.text) || '');\n cb.textContent = '已复制'; setTimeout(() => { cb.textContent = '复制'; }, 1200);\n }\n });\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('clawdy-theme') || 'light'); } catch (_) { applyTheme('light'); }\nrenderPresetGrid();\nbind();\nrefresh();\n"}, "f5045082bc95d25a1b5e5ee659b31339df843552": {"t": 1785766857.3492866, "n": 19894, "h": "c20a04ad98600837", "c": "'use strict';\n\nconst api = window.clawdy;\nlet config = { port: 8788, activeProviderId: null, providers: [] };\nlet status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' };\nlet editingId = null;\nlet dragId = null;\nconst stats = { total: 0, ok: 0, sumMs: 0, last: null };\n\nconst $ = (id) => document.getElementById(id);\n\nconst PRESETS = {\n glm: { name: '智谱 GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic', defaultModel: 'glm-5.1', smallFastModel: 'glm-5.1' },\n deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-chat', smallFastModel: 'deepseek-chat' },\n mimo: { name: '小米 MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: '', smallFastModel: '' },\n kimi: { name: '月之暗面 Kimi', baseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'kimi-k2-0905-preview', smallFastModel: 'kimi-k2-0905-preview' },\n custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '' },\n};\nconst PRESET_LABELS = { glm: '智谱 GLM', deepseek: 'DeepSeek', mimo: '小米 MiMo', kimi: '月之暗面 Kimi', custom: '自定义' };\n\n/* ---------- helpers ---------- */\nfunction escapeHtml(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\nconst activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null;\nfunction hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }\nfunction iconStyle(name) { const h = hashHue(name || '?'); return `background:linear-gradient(135deg,hsl(${h} 70% 58%),hsl(${(h + 40) % 360} 70% 48%))`; }\nfunction iconLetter(name) { return escapeHtml(((name || '?').trim()[0] || '?').toUpperCase()); }\nfunction mask(t) { return !t ? '未填密钥' : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); }\n\n/* ---------- hero / status ---------- */\nfunction showHeroNote(text, warn) {\n const n = $('heroNote');\n n.textContent = text;\n n.className = 'hero-note' + (warn ? ' warn' : '');\n}\nfunction hideHeroNote() { $('heroNote').className = 'hero-note hidden'; }\n\nfunction renderHero() {\n const hero = $('hero');\n const ap = activeProvider();\n if (status.connected) {\n hero.classList.add('connected');\n $('heroIcon').textContent = '✓';\n $('heroTitle').textContent = '已接入 Claude Code';\n $('heroSub').innerHTML = ap ? `正在通过 <b>${escapeHtml(ap.name)}</b> 工作 · 切换下方服务即时生效` : '已接入(请在下方选择一个服务)';\n $('btnConnect').textContent = '断开接入';\n showHeroNote('保持 Clawdy 运行即可持续工作。首次接入或更换端口后,需重启 Claude Code(新开会话)生效。', false);\n } else {\n hero.classList.remove('connected');\n $('heroIcon').textContent = '⏻';\n $('heroTitle').textContent = '未接入 Claude Code';\n $('heroSub').textContent = '打开开关,让 Claude Code 自动使用你选中的服务,随时一键切换或关闭。';\n $('btnConnect').textContent = '一键接入';\n hideHeroNote();\n }\n}\n\nfunction renderStatus() {\n const pill = $('statusPill');\n if (status.connected) {\n pill.className = 'pill pill-on';\n pill.innerHTML = '<span class=\"dot\"></span>已接入';\n $('brandTitle').classList.add('running');\n } else {\n pill.className = 'pill pill-off';\n pill.innerHTML = '<span class=\"dot\"></span>未接入';\n $('brandTitle').classList.remove('running');\n }\n}\n\nfunction renderConnect() {\n const port = (status.running && status.port) || config.port;\n $('endpoint').textContent = `http://127.0.0.1:${port}`;\n $('portInput').value = config.port;\n const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'clawdy-local';\n $('exportBlock').textContent = [\n `export ANTHROPIC_BASE_URL=http://127.0.0.1:${port}`,\n `export ANTHROPIC_AUTH_TOKEN=${token}`,\n '',\n '# 一般无需手动设置,点上方「一键接入」即可',\n ].join('\\n');\n $('claudePath').textContent = status.claudePath ? 'Claude 配置:' + status.claudePath : '';\n $('fOpenAtLogin').checked = !!config.openAtLogin;\n $('fRequireToken').checked = !!config.requireToken;\n $('fGatewayToken').value = config.gatewayToken || '';\n $('tokenRow').classList.toggle('hidden', !config.requireToken);\n const tu = config.trayUsage || { enabled: false, range: '7d' };\n $('fTrayUsage').checked = !!tu.enabled;\n $('fTrayRange').value = tu.range || '7d';\n $('trayRangeRow').classList.toggle('hidden', !tu.enabled);\n const se = $('startError');\n if (status.lastStartError) { se.textContent = '⚠ ' + status.lastStartError; se.classList.remove('hidden'); }\n else se.classList.add('hidden');\n}\n\nfunction renderProviders() {\n const list = $('providerList');\n list.innerHTML = '';\n $('emptyProviders').classList.toggle('hidden', config.providers.length > 0);\n for (const p of config.providers) {\n const isActive = p.id === config.activeProviderId;\n const el = document.createElement('div');\n el.className = 'provider' + (isActive ? ' active' : '');\n el.draggable = true;\n el.dataset.id = p.id;\n const tags = [];\n if (p.defaultModel) tags.push(`<span class=\"tag\">主 ${escapeHtml(p.defaultModel)}</span>`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`<span class=\"tag\">快 ${escapeHtml(p.smallFastModel)}</span>`);\n for (const m of p.models || []) tags.push(`<span class=\"tag map\">${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}</span>`);\n el.innerHTML = `\n <span class=\"grip\" title=\"拖动排序\">⠿</span>\n <span class=\"radio-wrap\"><input type=\"radio\" name=\"active\" ${isActive ? 'checked' : ''} data-act=\"${p.id}\" title=\"设为正在使用\" /></span>\n <div class=\"prov-icon\" style=\"${iconStyle(p.name)}\">${iconLetter(p.name)}</div>\n <div class=\"pinfo\">\n <div class=\"pname\">${escapeHtml(p.name)} ${isActive ? '<span class=\"badge-active\">正在使用</span>' : ''}</div>\n <div class=\"purl\" data-url=\"${escapeHtml(p.baseUrl)}\">${escapeHtml(p.baseUrl)} · ${escapeHtml(mask(p.authToken))}</div>\n <div class=\"pmodels\">${tags.join('') || '<span class=\"muted small\">未配置模型</span>'}</div>\n </div>\n <div class=\"pright\"><div class=\"pactions\">\n <button class=\"btn btn-sm\" data-test=\"${p.id}\">测试</button>\n <button class=\"btn btn-sm\" data-edit=\"${p.id}\">编辑</button>\n <button class=\"btn btn-sm btn-danger\" data-del=\"${p.id}\">删除</button>\n </div></div>`;\n list.appendChild(el);\n }\n}\n\nfunction renderMonitor() {\n $('mStatusText').textContent = status.connected ? '已接入' : status.running ? '运行中' : '未接入';\n $('mStatus').querySelector('.live-dot').className = 'live-dot ' + (status.connected || status.running ? 'on' : 'off');\n $('mEndpoint').textContent = `127.0.0.1:${(status.running && status.port) || config.port}`;\n const ap = activeProvider();\n $('mActive').textContent = ap ? ap.name : '—';\n $('mActiveUrl').textContent = ap ? ap.baseUrl : '未选择服务';\n $('mTotal').textContent = stats.total;\n $('mSuccess').textContent = stats.total ? `成功率 ${Math.round((stats.ok / stats.total) * 100)}%` : '成功率 —';\n $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} <span class=\"unit\">ms</span>` : `— <span class=\"unit\">ms</span>`;\n $('mLast').textContent = stats.last ? `最近 ${stats.last}` : '最近 —';\n}\n\nfunction renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); }\n\n/* ---------- monitor stream ---------- */\nfunction pushStreamRow(r) {\n stats.total++;\n if (r.status >= 200 && r.status < 400) stats.ok++;\n stats.sumMs += r.ms || 0;\n stats.last = new Date().toLocaleTimeString();\n renderMonitor();\n $('streamHint').textContent = `已转发 ${stats.total} 次`;\n const list = $('streamList');\n const empty = list.querySelector('.empty');\n if (empty) empty.remove();\n const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err';\n const row = document.createElement('div');\n row.className = 'stream-row';\n row.innerHTML = `\n <span class=\"sdot ${okCls}\"></span>\n <span class=\"method\">${escapeHtml(r.method || '')}</span>\n <span class=\"models\">\n <span class=\"req\">${escapeHtml(r.requestedModel || '-')}</span>\n <span class=\"arrow\">→</span>\n <span class=\"out\">${escapeHtml(r.outgoingModel || '-')}</span>\n ${r.rewritten ? '<span class=\"rewrite\" title=\"响应模型名已改回\">✎</span>' : ''}\n </span>\n <span class=\"prov\">${escapeHtml(r.provider || '')}</span>\n <span class=\"code ${okCls}\">${r.status}</span>\n <span class=\"ms\">${r.ms}ms</span>\n <span class=\"ts\">${new Date().toLocaleTimeString()}</span>`;\n list.insertBefore(row, list.firstChild);\n while (list.children.length > 120) list.removeChild(list.lastChild);\n}\nfunction pushRawLog(l) {\n const el = $('rawLog');\n const line = document.createElement('div');\n line.textContent = `[${new Date().toLocaleTimeString()}] ${l.level || 'info'} — ${l.msg}`;\n el.insertBefore(line, el.firstChild);\n while (el.children.length > 120) el.removeChild(el.lastChild);\n}\n\n/* ---------- modal ---------- */\nfunction renderPresetGrid() {\n const grid = $('presetGrid');\n grid.innerHTML = '';\n Object.keys(PRESET_LABELS).forEach((key) => {\n const b = document.createElement('button');\n b.type = 'button'; b.className = 'preset-chip'; b.dataset.preset = key; b.textContent = PRESET_LABELS[key];\n grid.appendChild(b);\n });\n}\nfunction selectPreset(key) {\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key));\n const p = PRESETS[key] || PRESETS.custom;\n $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel;\n updateIconPreview();\n if (key !== 'custom') $('fToken').focus();\n}\nfunction updateIconPreview() {\n const name = $('fName').value || '?';\n const el = $('fIconPreview');\n el.setAttribute('style', iconStyle(name));\n el.textContent = iconLetter(name);\n}\nfunction addMapRow(alias = '', upstream = '') {\n const row = document.createElement('div');\n row.className = 'map-row';\n row.innerHTML = `\n <input class=\"m-alias\" placeholder=\"别名 例如 claude-opus-4.8[1m]\" />\n <span class=\"map-arrow\">→</span>\n <input class=\"m-upstream\" placeholder=\"上游模型 例如 glm-5.2[1m]\" />\n <button class=\"icon-btn m-del\" type=\"button\">✕</button>`;\n row.querySelector('.m-alias').value = alias;\n row.querySelector('.m-upstream').value = upstream;\n row.querySelector('.m-del').addEventListener('click', () => row.remove());\n $('mapRows').appendChild(row);\n}\nfunction openModal(provider) {\n editingId = provider ? provider.id : null;\n $('modalTitle').textContent = provider ? '编辑服务' : '添加服务';\n document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected'));\n $('fName').value = provider ? provider.name : '';\n $('fBaseUrl').value = provider ? provider.baseUrl : '';\n $('fToken').value = provider ? provider.authToken : '';\n $('fToken').type = 'password'; $('fTokenToggle').textContent = '显示';\n $('fDefaultModel').value = provider ? provider.defaultModel : '';\n $('fSmallModel').value = provider ? provider.smallFastModel : '';\n $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true;\n $('mapRows').innerHTML = '';\n if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream));\n updateIconPreview();\n $('testResult').className = 'banner hidden';\n $('modal').classList.remove('hidden');\n $('fName').focus();\n}\nfunction closeModal() { $('modal').classList.add('hidden'); editingId = null; }\nfunction collectProvider() {\n const models = [];\n $('mapRows').querySelectorAll('.map-row').forEach((row) => {\n const alias = row.querySelector('.m-alias').value.trim();\n const upstream = row.querySelector('.m-upstream').value.trim();\n if (alias || upstream) models.push({ alias, upstream });\n });\n const p = {\n name: $('fName').value.trim() || '未命名',\n baseUrl: $('fBaseUrl').value.trim(),\n authToken: $('fToken').value.trim(),\n defaultModel: $('fDefaultModel').value.trim(),\n smallFastModel: $('fSmallModel').value.trim(),\n mapDefaultModels: $('fMapDefault').checked,\n models,\n };\n if (editingId) p.id = editingId;\n return p;\n}\n\n/* ---------- actions ---------- */\nasync function refresh() {\n config = await api.getConfig();\n status = await api.serverStatus();\n renderAll();\n}\nasync function persist(patch) {\n try { config = await api.saveConfig(Object.assign({}, config, patch)); }\n catch (e) { pushRawLog({ level: 'error', msg: '保存失败:' + (e && e.message ? e.message : e) }); }\n status = await api.serverStatus();\n renderAll();\n}\nasync function toggleConnect() {\n const btn = $('btnConnect');\n const wasConnected = status.connected;\n btn.disabled = true;\n btn.textContent = wasConnected ? '断开中…' : '接入中…';\n const res = wasConnected ? await api.disconnect() : await api.connect();\n btn.disabled = false;\n status = await api.serverStatus();\n renderAll();\n if (!res.ok) showHeroNote('⚠ ' + (res.message || '操作失败'), true);\n}\nfunction copyFeedback(btn, text) {\n const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent);\n api.copy(text);\n btn.textContent = '已复制 ✓';\n clearTimeout(btn._t);\n btn._t = setTimeout(() => (btn.textContent = orig), 1500);\n}\nfunction genToken() {\n const a = new Uint8Array(18);\n crypto.getRandomValues(a);\n return 'clawdy_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join('');\n}\nfunction switchView(view) {\n document.querySelectorAll('.seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view));\n $('view-providers').classList.toggle('hidden', view !== 'providers');\n $('view-monitor').classList.toggle('hidden', view !== 'monitor');\n $('btnAdd').classList.toggle('hidden', view !== 'providers');\n}\nfunction applyTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('clawdy-theme', t); } catch (_) {}\n}\n\n/* ---------- drag reorder ---------- */\nfunction wireDrag() {\n const list = $('providerList');\n list.addEventListener('dragstart', (e) => {\n const card = e.target.closest('.provider'); if (!card) return;\n dragId = card.dataset.id; card.classList.add('dragging');\n });\n list.addEventListener('dragend', (e) => {\n const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n });\n list.addEventListener('dragover', (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over'));\n if (card && card.dataset.id !== dragId) card.classList.add('drag-over');\n });\n list.addEventListener('drop', async (e) => {\n e.preventDefault();\n const card = e.target.closest('.provider');\n if (!card || !dragId || card.dataset.id === dragId) return;\n const ids = config.providers.map((p) => p.id);\n const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id);\n if (from < 0 || to < 0) return;\n const reordered = config.providers.slice();\n const [moved] = reordered.splice(from, 1);\n reordered.splice(to, 0, moved);\n await persist({ providers: reordered });\n });\n}\n\n/* ---------- wire up ---------- */\nfunction bind() {\n $('tabs').addEventListener('click', (e) => { if (e.target.dataset.view) switchView(e.target.dataset.view); });\n $('btnTheme').addEventListener('click', () => {\n const cur = document.documentElement.getAttribute('data-theme') || 'light';\n applyTheme(cur === 'light' ? 'dark' : 'light');\n });\n $('btnConnect').addEventListener('click', toggleConnect);\n\n $('portInput').addEventListener('change', async (e) => {\n const port = Number(e.target.value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n e.target.value = config.port;\n pushRawLog({ level: 'error', msg: '端口无效:请输入 1–65535 的整数' });\n return;\n }\n await persist({ port });\n });\n $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent));\n document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent)));\n\n $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked }));\n $('fRequireToken').addEventListener('change', (e) => {\n const requireToken = e.target.checked;\n const patch = { requireToken };\n if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken();\n persist(patch);\n });\n $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() }));\n $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true }));\n $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } }));\n $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } }));\n\n $('btnAdd').addEventListener('click', () => openModal(null));\n\n $('providerList').addEventListener('click', async (e) => {\n const t = e.target;\n if (t.dataset.edit) openModal(config.providers.find((p) => p.id === t.dataset.edit));\n else if (t.dataset.del) { if (confirm('确定删除这个服务?')) { config = await api.deleteProvider(t.dataset.del); renderAll(); } }\n else if (t.dataset.test) {\n const p = config.providers.find((pp) => pp.id === t.dataset.test);\n t.textContent = '测试中…'; t.disabled = true;\n const res = await api.testProvider(p);\n t.disabled = false; t.textContent = res.ok ? '✓ 正常' : '✗ 失败';\n pushRawLog({ level: res.ok ? 'info' : 'error', msg: `测试「${p.name}」: ${res.message}` });\n setTimeout(() => (t.textContent = '测试'), 2500);\n } else if (t.classList.contains('purl') && t.dataset.url) api.openExternal(t.dataset.url);\n });\n $('providerList').addEventListener('change', async (e) => {\n if (e.target.dataset.act) { config = await api.setActive(e.target.dataset.act); renderAll(); }\n });\n wireDrag();\n\n $('modalClose').addEventListener('click', closeModal);\n $('btnCancel').addEventListener('click', closeModal);\n $('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });\n $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); });\n $('fName').addEventListener('input', updateIconPreview);\n $('btnAddMap').addEventListener('click', () => addMapRow());\n $('fTokenToggle').addEventListener('click', () => {\n const f = $('fToken'); const show = f.type === 'password';\n f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? '隐藏' : '显示';\n });\n $('btnSave').addEventListener('click', async () => {\n const p = collectProvider();\n if (!p.baseUrl) { const tr = $('testResult'); tr.className = 'banner err'; tr.textContent = '请填写服务地址(API 地址)'; return; }\n config = await api.upsertProvider(p);\n closeModal(); renderAll();\n });\n $('btnTest').addEventListener('click', async () => {\n const tr = $('testResult'); tr.className = 'banner pending'; tr.textContent = '测试中…';\n const res = await api.testProvider(collectProvider());\n tr.className = 'banner ' + (res.ok ? 'ok' : 'err'); tr.textContent = (res.ok ? '✓ ' : '✗ ') + res.message;\n });\n\n $('btnClearLog').addEventListener('click', () => {\n $('streamList').innerHTML = '<div class=\"empty small\">还没有请求。</div>';\n $('rawLog').innerHTML = '';\n stats.total = stats.ok = stats.sumMs = 0; stats.last = null;\n $('streamHint').textContent = '等待请求…';\n renderMonitor();\n });\n\n api.onRequest((r) => pushStreamRow(r));\n api.onLog((l) => pushRawLog(l));\n api.onStatus((s) => { status = s; renderAll(); });\n}\n\ntry { applyTheme(localStorage.getItem('clawdy-theme') || 'light'); } catch (_) { applyTheme('light'); }\nrenderPresetGrid();\nbind();\nrefresh();\n"}}}}