{"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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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 = '';\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 = ``;\n const toggleBtn = ``;\n const delBtn = ``;\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 ``;\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
${iconData.html}
\n
\n
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
\n
${escapeHtml(p.description || '')}
\n
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
\n
\n
${actionBtns}${toggleBtn}${delBtn}
`;\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 = '

';\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 '';\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 = `${pluginSpinner()}${escapeHtml(text || '')}`;\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()}${escapeHtml(I18n.t('plugins.starting'))}`;\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 ``;\n }).join('');\n return ``;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return ``;\n }\n if (f.type === 'textarea') {\n return ``;\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 ``;\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 `).join('');\n ov.innerHTML = `\n
\n
\n

${escapeHtml(action.label || action.id)}

\n
\n
\n ${rows}\n
\n \n \n
\n
\n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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 = '';\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 = ``;\n const toggleBtn = ``;\n const delBtn = ``;\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 ``;\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
${iconData.html}
\n
\n
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
\n
${escapeHtml(p.description || '')}
\n
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
\n
\n
${actionBtns}${toggleBtn}${delBtn}
`;\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 = '

';\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 '';\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 = `${pluginSpinner()}${escapeHtml(text || '')}`;\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()}${escapeHtml(I18n.t('plugins.starting'))}`;\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 ``;\n }).join('');\n return ``;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return ``;\n }\n if (f.type === 'textarea') {\n return ``;\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 ``;\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 `).join('');\n ov.innerHTML = `\n
\n
\n

${escapeHtml(action.label || action.id)}

\n
\n
\n ${rows}\n
\n \n \n
\n
\n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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 = '';\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 = ``;\n const toggleBtn = ``;\n const delBtn = ``;\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 ``;\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
${iconData.html}
\n
\n
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
\n
${escapeHtml(p.description || '')}
\n
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
\n
\n
${actionBtns}${toggleBtn}${delBtn}
`;\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 = '

';\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 '';\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 = `${pluginSpinner()}${escapeHtml(text || '')}`;\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()}${escapeHtml(I18n.t('plugins.starting'))}`;\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 ``;\n }).join('');\n return ``;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return ``;\n }\n if (f.type === 'textarea') {\n return ``;\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 ``;\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 `).join('');\n ov.innerHTML = `\n
\n
\n

${escapeHtml(action.label || action.id)}

\n
\n
\n ${rows}\n
\n \n \n
\n
\n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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 = '';\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 = ``;\n const toggleBtn = ``;\n const delBtn = ``;\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 ``;\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
${iconData.html}
\n
\n
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
\n
${escapeHtml(p.description || '')}
\n
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
\n
\n
${actionBtns}${toggleBtn}${delBtn}
`;\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 = '

';\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 '';\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 = `${pluginSpinner()}${escapeHtml(text || '')}`;\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()}${escapeHtml(I18n.t('plugins.starting'))}`;\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 ``;\n }).join('');\n return ``;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return ``;\n }\n if (f.type === 'textarea') {\n return ``;\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 ``;\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 `).join('');\n ov.innerHTML = `\n
\n
\n

${escapeHtml(action.label || action.id)}

\n
\n
\n ${rows}\n
\n \n \n
\n
\n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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 = '';\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 = ``;\n const toggleBtn = ``;\n const delBtn = ``;\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 ``;\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
${iconData.html}
\n
\n
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
\n
${escapeHtml(p.description || '')}
\n
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
\n
\n
${actionBtns}${toggleBtn}${delBtn}
`;\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 = '

';\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 '';\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 = `${pluginSpinner()}${escapeHtml(text || '')}`;\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()}${escapeHtml(I18n.t('plugins.starting'))}`;\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 ``;\n }).join('');\n return ``;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return ``;\n }\n if (f.type === 'textarea') {\n return ``;\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 ``;\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 `).join('');\n ov.innerHTML = `\n
\n
\n

${escapeHtml(action.label || action.id)}

\n
\n
\n ${rows}\n
\n \n \n
\n
\n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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 = '';\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 = ``;\n const toggleBtn = ``;\n const delBtn = ``;\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 ``;\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
${iconData.html}
\n
\n
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
\n
${escapeHtml(p.description || '')}
\n
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
\n
\n
${actionBtns}${toggleBtn}${delBtn}
`;\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 = '

';\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 '';\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 = `${pluginSpinner()}${escapeHtml(text || '')}`;\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()}${escapeHtml(I18n.t('plugins.starting'))}`;\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 ``;\n }).join('');\n return ``;\n }\n if (f.type === 'checkbox') {\n const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true';\n return ``;\n }\n if (f.type === 'textarea') {\n return ``;\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 ``;\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 `).join('');\n ov.innerHTML = `\n
\n
\n

${escapeHtml(action.label || action.id)}

\n
\n
\n ${rows}\n
\n \n \n
\n
\n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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]) => ``)\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'))} ${escapeHtml(view.sub)}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\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 = `${escapeHtml(protoMeta.label)}`;\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `\"\"` };\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: `\"\"` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ``;\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: `${escapeHtml(emoji)}` };\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: `` };\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: `` };\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: `` };\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 `
${escapeHtml(I18n.t('settings.presidioFindNone'))}
`;\n const head = `${escapeHtml(I18n.t('settings.findEntity'))}${escapeHtml(I18n.t('settings.findText'))}${escapeHtml(I18n.t('settings.findScore'))}`;\n const body = rows.slice().reverse().map((f) => `${escapeHtml(f.entity || '')}${escapeHtml(f.text || '')}${f.score != null ? f.score : ''}`).join('');\n return `${head}${body}
`;\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 ? `${escapeHtml(I18n.t('settings.dirMissing'))}`\n : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`;\n return `
\n ${escapeHtml(d.label)}\n ${status}\n \n
`;\n }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`;\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(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name, p.icon);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? `sub` : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? `✎` : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${fmtTime()}`;\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 = `${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 = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`;\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 `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`;\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 `
${escapeHtml(I18n.t('drawer.empty'))}
`;\n const { text, lang } = prettyText(cap);\n const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : '';\n return note + `
${escapeHtml(text)}
`;\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)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + '';\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 `
${escapeHtml(I18n.t('drawer.none'))}
`;\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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'))} ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `${escapeHtml(I18n.t('drawer.resHeaders'))} HTTP ${escapeHtml(d.status || '')}`;\n drBodyText = prettyText(cap).text;\n drMatches = []; drMatchIdx = -1;\n const searchBar = drBodyText ? `
\n \n 0/0\n \n \n
` : '';\n const copyCls = drBodyText ? '' : 'ml-auto ';\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${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 = `
${escapeHtml(I18n.t('drawer.expired'))}
`;\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ✎` : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 `
${ICON_EMOJIS.map((e) => ``).join('')}
` +\n `
` +\n ``;\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 \n →\n \n `;\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. 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 = `
\n

${escapeHtml(title || '')}

\n

${escapeHtml(message || '')}

\n
\n \n \n
\n
`;\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 + 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 = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`;\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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: ``\n };\n }\n if (n.includes('deepseek')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: ``\n };\n }\n if (n.includes('glm') || n.includes('智谱') || n.includes('bigmodel')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: ``\n };\n }\n if (n.includes('mimo') || n.includes('小米') || n.includes('xiaomi')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: ``\n };\n }\n if (n.includes('zenmux')) {\n return {\n style: 'background: transparent; box-shadow: none;',\n html: ``\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 = ``;\n } else {\n // Custom generic high-tech cube icon\n const h = hashHue(name || '?');\n themeColor = `hsl(${h}, 70%, 52%)`;\n svgContent = ``;\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: `${svgContent}`\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 ? `经 ${escapeHtml(ap.name)} 转发 · 点卡片切换` : '网关运行中';\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 = '已接入';\n $('brandTitle').classList.add('running');\n } else {\n chip.className = 'status-chip';\n chip.innerHTML = '未接入';\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(`主 ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`快 ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n\n const iconData = renderProviderIcon(p.name);\n el.innerHTML = `\n ⠿\n
${iconData.html}
\n
\n
${escapeHtml(p.name)} ${isActive ? '活跃' : ''}
\n
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\\/\\//,''))}
\n
\n
${tags.join('') || '—'}
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 ? '子' : '';\n row.innerHTML = `\n \n ${escapeHtml(r.method || '')}\n ${agentTag}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? '✎' : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${new Date().toLocaleTimeString()}`;\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 '
(空)
';\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 ? `
仅显示前 ${fmtBytes(cap.bytes - cap.truncated)} / 共 ${fmtBytes(cap.bytes)}(已截断)
` : '';\n return note + `
${escapeHtml(text)}
`;\n}\nfunction kvTable(h) {\n const keys = Object.keys(h || {});\n if (!keys.length) return '
(无)
';\n return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
';\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 ? `请求头 ${escapeHtml(d.method || 'POST')} ${escapeHtml(d.url || d.path || '')}`\n : `响应头 HTTP ${escapeHtml(d.status || '')}`;\n body.innerHTML = `
${headTitle}
${kvTable(headers)}
${isReq ? '请求体' : '响应体'}
${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 = '
该请求的详情已不可用(仅保留最近 30 条转发的完整报文)。
';\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 || '-')} → ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ' ✎' : ''}`;\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) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).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 \n →\n \n `;\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 = '
接入网关后,转发记录将实时显示
';\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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[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 ? `正在通过 ${escapeHtml(ap.name)} 工作 · 切换下方服务即时生效` : '已接入(请在下方选择一个服务)';\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 = '已接入';\n $('brandTitle').classList.add('running');\n } else {\n pill.className = 'pill pill-off';\n pill.innerHTML = '未接入';\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(`主 ${escapeHtml(p.defaultModel)}`);\n if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`快 ${escapeHtml(p.smallFastModel)}`);\n for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`);\n el.innerHTML = `\n ⠿\n \n
${iconLetter(p.name)}
\n
\n
${escapeHtml(p.name)} ${isActive ? '正在使用' : ''}
\n
${escapeHtml(p.baseUrl)} · ${escapeHtml(mask(p.authToken))}
\n
${tags.join('') || '未配置模型'}
\n
\n
\n \n \n \n
`;\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)} ms` : `— ms`;\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 \n ${escapeHtml(r.method || '')}\n \n ${escapeHtml(r.requestedModel || '-')}\n →\n ${escapeHtml(r.outgoingModel || '-')}\n ${r.rewritten ? '✎' : ''}\n \n ${escapeHtml(r.provider || '')}\n ${r.status}\n ${r.ms}ms\n ${new Date().toLocaleTimeString()}`;\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 \n →\n \n `;\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 = '
还没有请求。
';\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"}}}}