Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/andreidima11_hassai-bridge.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
101 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "andreidima11/hassai-bridge", "files": {"static/js/app.js": {"20f65ce195cb245ea4f0f22ddd02b909c0733506": {"t": 1786068492.774896, "n": 97301, "h": "1a5876a2e231ef71", "c": "// ── HASSAI Bridge v2 — Frontend ──\n\nconst API = '';\n\n// ── Tabs ──\ndocument.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n document.querySelectorAll('.container > .panel').forEach(p => p.classList.remove('active'));\n tab.classList.add('active');\n const panelId = tab.dataset.panel;\n document.getElementById(panelId).classList.add('active');\n if (panelId === 'statistics') loadUsageStats();\n if (panelId === 'logs') { loadLogs(); _startLogsAutoRefresh(); }\n if (panelId === 'skills') loadSkills();\n if (panelId === 'conversations') refreshConvUsers();\n if (panelId === 'memories') refreshMemTabUsers();\n });\n});\n\nfunction switchToTab(panelId) {\n document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n document.querySelectorAll('.container > .panel').forEach(p => p.classList.remove('active'));\n const tab = document.querySelector(`.tab[data-panel=\"${panelId}\"]`);\n if (tab) tab.classList.add('active');\n const panel = document.getElementById(panelId);\n if (panel) panel.classList.add('active');\n if (panelId === 'statistics') loadUsageStats();\n if (panelId === 'logs') { loadLogs(); _startLogsAutoRefresh(); }\n if (panelId === 'skills') loadSkills();\n if (panelId === 'conversations') refreshConvUsers();\n if (panelId === 'memories') refreshMemTabUsers();\n}\n\n// ── Settings sub-tabs (used in both Settings and Statistics) ──\ndocument.querySelectorAll('.settings-tab').forEach(stab => {\n stab.addEventListener('click', () => {\n const parent = stab.closest('.panel') || stab.parentElement.parentElement;\n parent.querySelectorAll('.settings-tab').forEach(s => s.classList.remove('active'));\n parent.querySelectorAll('.settings-subpanel').forEach(p => p.classList.remove('active'));\n stab.classList.add('active');\n document.getElementById(stab.dataset.stab).classList.add('active');\n if (stab.dataset.stab === 'stab-stats-server') {\n requestAnimationFrame(fitServerOverviewValues);\n }\n });\n});\n\n// ── Toast ──\nfunction toast(msg, isError = false) {\n const el = document.getElementById('toast');\n el.textContent = msg;\n el.className = 'toast show' + (isError ? ' error' : '');\n setTimeout(() => el.className = 'toast', 3000);\n}\n\n// ── API helpers ──\nasync function api(method, path, body = null) {\n const opts = { method, headers: { 'Content-Type': 'application/json' } };\n if (body) opts.body = JSON.stringify(body);\n const resp = await fetch(API + path, opts);\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n return resp.json();\n}\n\nfunction escapeHtml(s) {\n const d = document.createElement('div');\n d.textContent = s || '';\n return d.innerHTML;\n}\n\n// ══════════════════════════════════════════════════\n// INFO TAB\n// ══════════════════════════════════════════════════\n\nfunction formatUptime(seconds) {\n const d = Math.floor(seconds / 86400);\n const h = Math.floor((seconds % 86400) / 3600);\n const m = Math.floor((seconds % 3600) / 60);\n if (d > 0) return `${d}d ${h}h ${m}m`;\n if (h > 0) return `${h}h ${m}m`;\n return `${m}m`;\n}\n\nfunction fitTextToOneLine(el, maxPx = 32, minPx = 12) {\n if (!el || el.clientWidth === 0) return;\n el.style.whiteSpace = 'nowrap';\n let size = maxPx;\n el.style.fontSize = `${size}px`;\n while (size > minPx && el.scrollWidth > el.clientWidth) {\n size -= 1;\n el.style.fontSize = `${size}px`;\n }\n}\n\nfunction fitServerOverviewValues() {\n fitTextToOneLine(document.getElementById('statsServerUptime'), 34, 12);\n fitTextToOneLine(document.getElementById('statsServerVersion'), 34, 11);\n fitTextToOneLine(document.getElementById('statsServerEndpoints'), 34, 12);\n fitTextToOneLine(document.getElementById('statsServerProviders'), 34, 12);\n}\n\nfunction updateEndpointDisplay(ip, port) {\n const base = ip && port ? `http://${ip}:${port}` : `${window.location.protocol}//${window.location.host}`;\n const htcUrl = document.getElementById('htcApiUrl');\n if (htcUrl) htcUrl.textContent = base;\n}\n\nlet _apiKeyVisible = false;\nlet _apiKeyValue = '';\n\nfunction toggleHtcKey() {\n _apiKeyVisible = !_apiKeyVisible;\n const el = document.getElementById('htcApiKey');\n if (_apiKeyVisible && _apiKeyValue) {\n el.textContent = _apiKeyValue;\n el.classList.remove('api-key-blur');\n } else {\n el.textContent = '••••••••••••••••';\n el.classList.add('api-key-blur');\n }\n}\n\nfunction copyHtcText(btn) {\n const codeEl = btn.closest('.htc-code-box').querySelector('code');\n let text = codeEl.textContent;\n if (codeEl.id === 'htcApiKey' && !_apiKeyVisible && _apiKeyValue) {\n text = _apiKeyValue;\n }\n if (navigator.clipboard && window.isSecureContext) {\n navigator.clipboard.writeText(text).then(() => toast(t('toast.copied'))).catch(() => _copyFallback(text));\n } else {\n _copyFallback(text);\n }\n}\n\nfunction copyText(elementId) {\n const el = document.getElementById(elementId);\n let text = el.textContent;\n if (navigator.clipboard && window.isSecureContext) {\n navigator.clipboard.writeText(text).then(() => toast(t('toast.copied'))).catch(() => _copyFallback(text));\n } else {\n _copyFallback(text);\n }\n}\n\nfunction _copyFallback(text, msg) {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.left = '-9999px';\n ta.style.opacity = '0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n try {\n document.execCommand('copy');\n toast(msg || t('toast.copied'));\n } catch (e) {\n toast(t('toast.copyFail'));\n }\n document.body.removeChild(ta);\n}\n\nasync function loadSystemInfo() {\n try {\n const info = await api('GET', '/api/settings/info');\n\n // LMStudio / Provider\n const lm = info.services.lmstudio;\n const prov = info.services.provider || lm;\n const sx = info.services.searxng;\n const mem = info.services.memory;\n\n const lmCard = document.getElementById('svcLMStudio');\n const sxCard = document.getElementById('svcSearXNG');\n const memCard = document.getElementById('svcMemory');\n\n // Provider\n const lmOnline = (prov.status || lm.status) === 'connected';\n lmCard.className = 'service-card ' + (lmOnline ? 'online' : 'offline');\n lmCard.querySelector('.svc-status').className = 'svc-status ' + (lmOnline ? 'online' : 'offline');\n lmCard.querySelector('.svc-status').textContent = lmOnline ? t('status.connected') : t('status.unavailable');\n const provName = prov.name || lm.model || 'AI Provider';\n document.getElementById('svcProviderName').textContent = provName;\n document.getElementById('svcLMDetail').textContent = `${prov.url || lm.url} — ${prov.model || lm.model}`;\n\n // SearXNG\n const sxOnline = sx.status === 'connected';\n sxCard.className = 'service-card ' + (sx.enabled ? (sxOnline ? 'online' : 'offline') : '');\n const sxStatusEl = sxCard.querySelector('.svc-status');\n if (!sx.enabled) {\n sxStatusEl.className = 'svc-status disabled';\n sxStatusEl.textContent = t('status.disabled');\n } else {\n sxStatusEl.className = 'svc-status ' + (sxOnline ? 'online' : 'offline');\n sxStatusEl.textContent = sxOnline ? t('status.connected') : t('status.unavailable');\n }\n document.getElementById('svcSXDetail').textContent = sx.url;\n\n // Memory\n memCard.className = 'service-card ' + (mem.enabled ? 'online' : '');\n const memStatusEl = memCard.querySelector('.svc-status');\n if (mem.enabled) {\n memStatusEl.className = 'svc-status online';\n memStatusEl.textContent = mem.auto_extract ? t('status.activeAutoExtract') : t('status.active');\n } else {\n memStatusEl.className = 'svc-status disabled';\n memStatusEl.textContent = t('status.disabled');\n }\n document.getElementById('svcMemDetail').textContent = t('status.memoriesStored', { count: info.stats.total_memories });\n\n // Stats\n document.getElementById('statUptime').textContent = formatUptime(info.uptime_seconds);\n document.getElementById('statUsers').textContent = info.stats.total_users;\n document.getElementById('statMemories').textContent = info.stats.total_memories;\n document.getElementById('statConversations').textContent = info.stats.total_conversations;\n document.getElementById('statActions24h').textContent = info.stats.actions_last_24h;\n\n // Version badge\n if (info.version) {\n document.getElementById('versionBadge').textContent = info.version;\n }\n\n // API Key\n if (info.api_key) {\n _apiKeyValue = info.api_key;\n }\n\n // Update endpoints with real LAN IP\n updateEndpointDisplay(info.local_ip, info.port);\n\n // Endpoints table (hidden by default, loaded for toggle)\n const table = document.getElementById('endpointsTable');\n table.innerHTML = info.endpoints.map(ep => `\n <div class=\"ep-row\">\n <span class=\"ep-method ${ep.method.toLowerCase()}\">${escapeHtml(ep.method)}</span>\n <span class=\"ep-path\">${escapeHtml(ep.path)}</span>\n <span class=\"ep-desc\">${escapeHtml(ep.description)}</span>\n </div>\n `).join('');\n\n } catch (e) {\n toast(t('toast.infoError', { msg: e.message }), true);\n }\n}\n\nfunction refreshInfo() {\n loadSystemInfo();\n toast(t('toast.infoRefreshed'));\n}\n\nfunction toggleEndpoints() {\n const table = document.getElementById('endpointsTable');\n const arrow = document.getElementById('epToggleArrow');\n if (table.style.display === 'none') {\n table.style.display = '';\n arrow.classList.add('open');\n } else {\n table.style.display = 'none';\n arrow.classList.remove('open');\n }\n}\n\n// ══════════════════════════════════════════════════\n// STATISTICS TAB\n// ══════════════════════════════════════════════════\n\nconst CHART_COLORS = [\n '#4f8cff', '#2ecc71', '#e74c3c', '#f1c40f', '#9b59b6',\n '#1abc9c', '#e67e22', '#3498db', '#e84393', '#00cec9',\n '#fd79a8', '#6c5ce7', '#00b894', '#fdcb6e',\n];\n\nfunction _drawPieChart(canvas, data, colors) {\n const ctx = canvas.getContext('2d');\n const dpr = window.devicePixelRatio || 1;\n const size = Math.min(canvas.parentElement.clientWidth - 40, 280);\n canvas.width = size * dpr;\n canvas.height = size * dpr;\n canvas.style.width = size + 'px';\n canvas.style.height = size + 'px';\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n const total = data.reduce((s, d) => s + d.value, 0);\n if (total === 0) {\n ctx.fillStyle = '#7a7e92';\n ctx.font = '14px sans-serif';\n ctx.textAlign = 'center';\n ctx.fillText(t('stats.noData'), size / 2, size / 2);\n return;\n }\n const cx = size / 2, cy = size / 2, r = (size / 2) - 10;\n let startAngle = -Math.PI / 2;\n\n data.forEach((d, i) => {\n const sliceAngle = (d.value / total) * 2 * Math.PI;\n ctx.beginPath();\n ctx.moveTo(cx, cy);\n ctx.arc(cx, cy, r, startAngle, startAngle + sliceAngle);\n ctx.closePath();\n ctx.fillStyle = colors[i % colors.length];\n ctx.fill();\n\n // Percentage label\n if (d.value / total > 0.05) {\n const midAngle = startAngle + sliceAngle / 2;\n const lx = cx + (r * 0.65) * Math.cos(midAngle);\n const ly = cy + (r * 0.65) * Math.sin(midAngle);\n ctx.fillStyle = '#fff';\n ctx.font = 'bold 12px sans-serif';\n ctx.textAlign = 'center';\n ctx.textBaseline = 'middle';\n ctx.fillText(Math.round(d.value / total * 100) + '%', lx, ly);\n }\n startAngle += sliceAngle;\n });\n\n // Donut hole\n ctx.beginPath();\n ctx.arc(cx, cy, r * 0.45, 0, Math.PI * 2);\n ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--card').trim() || '#181b25';\n ctx.fill();\n}\n\nfunction _drawBarChart(canvas, labels, values, color) {\n const ctx = canvas.getContext('2d');\n const dpr = window.devicePixelRatio || 1;\n const w = canvas.parentElement.clientWidth - 44;\n const h = 180;\n canvas.width = w * dpr;\n canvas.height = h * dpr;\n canvas.style.width = w + 'px';\n canvas.style.height = h + 'px';\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n if (!values.length) {\n ctx.fillStyle = '#7a7e92';\n ctx.font = '14px sans-serif';\n ctx.textAlign = 'center';\n ctx.fillText(t('stats.noData'), w / 2, h / 2);\n return;\n }\n\n const maxVal = Math.max(...values, 1);\n const barW = Math.max(4, Math.min(30, (w - 40) / values.length - 2));\n const gap = Math.max(1, ((w - 40) - barW * values.length) / Math.max(values.length - 1, 1));\n const chartH = h - 34;\n const startX = 30;\n\n // Grid lines\n ctx.strokeStyle = 'rgba(122,126,146,.15)';\n ctx.lineWidth = 1;\n for (let i = 0; i <= 4; i++) {\n const y = 4 + (chartH / 4) * i;\n ctx.beginPath();\n ctx.moveTo(startX, y);\n ctx.lineTo(w, y);\n ctx.stroke();\n ctx.fillStyle = '#7a7e92';\n ctx.font = '10px sans-serif';\n ctx.textAlign = 'right';\n ctx.fillText(Math.round(maxVal - (maxVal / 4) * i), startX - 4, y + 3);\n }\n\n // Bars\n values.forEach((v, i) => {\n const barH = (v / maxVal) * chartH;\n const x = startX + i * (barW + gap);\n const y = 4 + chartH - barH;\n ctx.fillStyle = color || '#4f8cff';\n ctx.beginPath();\n const radius = Math.min(3, barW / 2);\n ctx.moveTo(x, y + radius);\n ctx.arcTo(x, y, x + barW, y, radius);\n ctx.arcTo(x + barW, y, x + barW, y + barH, radius);\n ctx.lineTo(x + barW, 4 + chartH);\n ctx.lineTo(x, 4 + chartH);\n ctx.closePath();\n ctx.fill();\n\n // Label every Nth bar\n if (labels.length <= 14 || i % Math.ceil(labels.length / 14) === 0) {\n ctx.save();\n ctx.translate(x + barW / 2, h - 2);\n ctx.rotate(-Math.PI / 4);\n ctx.fillStyle = '#7a7e92';\n ctx.font = '9px sans-serif';\n ctx.textAlign = 'right';\n ctx.fillText(labels[i].slice(5), 0, 0); // show MM-DD\n ctx.restore();\n }\n });\n}\n\nfunction _buildLegend(container, data, colors) {\n container.innerHTML = data.map((d, i) =>\n `<span class=\"chart-legend-item\"><span class=\"chart-legend-dot\" style=\"background:${colors[i % colors.length]}\"></span>${escapeHtml(d.label)} (${d.value})</span>`\n ).join('');\n}\n\nfunction _formatNumber(n) {\n if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';\n if (n >= 1000) return (n / 1000).toFixed(1) + 'K';\n return String(n);\n}\n\nfunction _formatMs(ms) {\n if (ms >= 60000) return (ms / 60000).toFixed(1) + 'm';\n if (ms >= 1000) return (ms / 1000).toFixed(1) + 's';\n return ms + 'ms';\n}\n\nasync function loadUsageStats() {\n const days = parseInt(document.getElementById('statsPeriod').value) || 30;\n try {\n const stats = await api('GET', `/api/settings/stats?days=${days}`);\n\n // Overview\n document.getElementById('statsRequests').textContent = _formatNumber(stats.total_requests);\n document.getElementById('statsTokens').textContent = _formatNumber(stats.tokens.total);\n document.getElementById('statsSearches').textContent = _formatNumber(stats.search_requests);\n\n // Skills count\n try {\n const skillsList = await api('GET', '/api/skills/');\n const enabledSkills = skillsList.filter(s => !s.disabled).length;\n document.getElementById('statsSkills').textContent = `${enabledSkills} / ${skillsList.length}`;\n } catch {\n document.getElementById('statsSkills').textContent = '—';\n }\n\n // Eco Mode stats\n const eco = stats.eco_mode || {};\n document.getElementById('statsEcoRequests').textContent = _formatNumber(eco.requests || 0);\n document.getElementById('statsEcoSaved').textContent = _formatNumber(eco.saved_tokens || 0);\n document.getElementById('statsEcoAvg').textContent = _formatNumber(eco.avg_completion_eco || 0);\n document.getElementById('statsNormalAvg').textContent = _formatNumber(eco.avg_completion_normal || 0);\n\n // Secondary Provider stats\n const sec = stats.secondary || {};\n document.getElementById('statsSecondaryRequests').textContent = _formatNumber(sec.requests || 0);\n document.getElementById('statsSecondaryTokens').textContent = _formatNumber(sec.tokens || 0);\n\n // Pie chart - by provider\n const provData = stats.by_provider.map(p => ({ label: p.provider_name || p.provider_id, value: p.requests }));\n _drawPieChart(document.getElementById('chartProvider'), provData, CHART_COLORS);\n _buildLegend(document.getElementById('chartProviderLegend'), provData, CHART_COLORS);\n\n // Pie chart - by model\n const modelData = stats.by_model.map(m => ({ label: m.model, value: m.requests }));\n _drawPieChart(document.getElementById('chartModel'), modelData, CHART_COLORS);\n _buildLegend(document.getElementById('chartModelLegend'), modelData, CHART_COLORS);\n\n // Daily bar chart\n const dailyLabels = stats.daily.map(d => d.day);\n const dailyValues = stats.daily.map(d => d.requests);\n _drawBarChart(document.getElementById('chartDaily'), dailyLabels, dailyValues, '#4f8cff');\n\n // Provider detail table\n document.getElementById('statsProviderTable').innerHTML = stats.by_provider.length\n ? stats.by_provider.map(p => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(p.provider_name || p.provider_id)} <span class=\"stats-detail-badge\">${escapeHtml(p.provider_type)}</span></span>\n <span class=\"stats-detail-num\">${p.requests} req</span>\n <span class=\"stats-detail-meta\">${_formatNumber(p.tokens)} tok</span>\n <span class=\"stats-detail-meta\">${_formatMs(p.avg_response_ms)} avg</span>\n </div>`).join('')\n : `<p class=\"card-muted\">${t('stats.noData')}</p>`;\n\n // Model detail table\n document.getElementById('statsModelTable').innerHTML = stats.by_model.length\n ? stats.by_model.map(m => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(m.model)} <span class=\"stats-detail-badge\">${escapeHtml(m.provider_type)}</span></span>\n <span class=\"stats-detail-num\">${m.requests} req</span>\n <span class=\"stats-detail-meta\">${_formatNumber(m.tokens)} tok</span>\n <span class=\"stats-detail-meta\">${_formatMs(m.avg_response_ms)} avg</span>\n </div>`).join('')\n : `<p class=\"card-muted\">${t('stats.noData')}</p>`;\n\n // User detail table\n document.getElementById('statsUserTable').innerHTML = stats.by_user.length\n ? stats.by_user.map(u => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(u.user_id)}</span>\n <span class=\"stats-detail-num\">${u.requests} req</span>\n <span class=\"stats-detail-meta\">${_formatNumber(u.tokens)} tok</span>\n </div>`).join('')\n : `<p class=\"card-muted\">${t('stats.noData')}</p>`;\n\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n // Load sub-tab data\n loadStatsMemory();\n loadStatsSkills();\n loadStatsServer();\n}\n\n// ══════════════════════════════════════════════════\n// SETTINGS\n// ══════════════════════════════════════════════════\n\nasync function loadSettings() {\n try {\n const cfg = await api('GET', '/api/settings/');\n\n // Apply saved language\n const savedLang = cfg.language || 'en';\n if (savedLang !== currentLang) {\n setLanguage(savedLang, true);\n }\n document.getElementById('settingsLang').value = savedLang;\n document.getElementById('langSelect').value = savedLang;\n\n // Providers\n _allProviders = cfg.providers || [];\n _activeProviderId = cfg.active_provider || '';\n _allSecondaryProviders = cfg.secondary_providers || [];\n renderProvidersList();\n renderSecondaryProvidersList();\n\n // SearXNG\n document.getElementById('sxEnabled').checked = cfg.searxng.enabled;\n document.getElementById('knowledgeCutoff').value = cfg.knowledge_cutoff || '';\n document.getElementById('sxUrl').value = cfg.searxng.base_url;\n document.getElementById('sxMaxResults').value = cfg.searxng.max_results;\n document.getElementById('sxMaxChars').value = cfg.searxng.max_page_chars;\n document.getElementById('sxCacheTtl').value = cfg.searxng.cache_ttl || 300;\n\n // Memory\n document.getElementById('memEnabled').checked = cfg.memory.enabled;\n\n // Auto-consolidation\n const ac = cfg.memory.auto_consolidation || {};\n document.getElementById('acEnabled').checked = ac.enabled || false;\n document.getElementById('acSchedule').value = ac.schedule || 'daily';\n document.getElementById('acHour').value = ac.hour ?? 3;\n document.getElementById('memAutoExtract').checked = cfg.memory.auto_extract;\n document.getElementById('memMax').value = cfg.memory.max_memories_per_user;\n\n // Performance\n const perf = cfg.performance || {};\n document.getElementById('perfHistoryLimit').value = perf.history_limit || 10;\n document.getElementById('perfParallelFetch').checked = perf.parallel_page_fetch !== false;\n\n // Security\n const sec = cfg.security || {};\n const _defaultEcoPrompt = 'Be concise. No filler words, no pleasantries, no sign-offs. Answer directly without restating the question. Skip explanations unless explicitly asked. Keep responses short and to the point.';\n document.getElementById('securityEcoPrompt').value = sec.eco_prompt || _defaultEcoPrompt;\n\n // System prompt\n document.getElementById('systemPrompt').value = cfg.system_prompt || '';\n } catch (e) {\n toast(t('toast.settingsError', { msg: e.message }), true);\n }\n}\n\nasync function saveSettings() {\n try {\n await api('PUT', '/api/settings/', {\n searxng: {\n enabled: document.getElementById('sxEnabled').checked,\n base_url: document.getElementById('sxUrl').value,\n max_results: parseInt(document.getElementById('sxMaxResults').value),\n max_page_chars: parseInt(document.getElementById('sxMaxChars').value),\n auto_consolidation: {\n enabled: document.getElementById('acEnabled').checked,\n schedule: document.getElementById('acSchedule').value,\n hour: parseInt(document.getElementById('acHour').value) || 3,\n },\n cache_ttl: parseInt(document.getElementById('sxCacheTtl').value),\n },\n memory: {\n enabled: document.getElementById('memEnabled').checked,\n auto_extract: document.getElementById('memAutoExtract').checked,\n max_memories_per_user: parseInt(document.getElementById('memMax').value),\n },\n performance: {\n history_limit: parseInt(document.getElementById('perfHistoryLimit').value),\n parallel_page_fetch: document.getElementById('perfParallelFetch').checked,\n },\n system_prompt: document.getElementById('systemPrompt').value,\n knowledge_cutoff: document.getElementById('knowledgeCutoff').value,\n language: document.getElementById('settingsLang').value,\n });\n toast(t('toast.settingsSaved'));\n loadSystemInfo();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function saveSecuritySettings() {\n try {\n await api('PUT', '/api/settings/', {\n security: {\n eco_prompt: document.getElementById('securityEcoPrompt').value,\n },\n });\n toast(t('toast.settingsSaved'));\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function checkHealth() {\n try {\n const h = await api('GET', '/api/settings/health');\n const provOk = (h.provider || h.lmstudio) === 'connected';\n const provLabel = h.provider_name || 'AI';\n toast(`${provLabel}: ${h.provider || h.lmstudio} | SearXNG: ${h.searxng}`);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function testProvider(id) {\n try {\n const h = await api('GET', `/api/settings/providers/${encodeURIComponent(id)}/health`);\n const status = h.status || h.provider || 'unknown';\n const model = h.model || '';\n toast(`${status === 'connected' ? '✅' : '❌'} ${status}${model ? ' — ' + model : ''}`);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function testSecondaryProvider(id) {\n try {\n const h = await api('GET', `/api/settings/secondary-providers/${encodeURIComponent(id)}/health`);\n const status = h.status || 'unknown';\n const model = h.model || '';\n toast(`${status === 'connected' ? '✅' : '❌'} ${status}${model ? ' — ' + model : ''}`);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function testSearxng() {\n try {\n const h = await api('GET', '/api/settings/health');\n toast(`SearXNG: ${h.searxng === 'connected' ? '✅ ' + t('status.connected') : '❌ ' + t('status.unavailable')}`);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\n// ══════════════════════════════════════════════════\n// PROVIDERS MANAGEMENT\n// ══════════════════════════════════════════════════\n\nlet _allProviders = [];\nlet _allSecondaryProviders = [];\nlet _activeProviderId = '';\nlet _editingProviderId = null; // null = adding new, string = editing existing\nlet _editingSecProviderId = null; // null = adding new, string = editing existing\n\nconst PROVIDER_TYPE_LABELS = {\n local: 'Local (LM Studio / Ollama)',\n openai: 'OpenAI',\n grok: 'Grok (xAI)',\n deepseek: 'DeepSeek',\n glm: 'GLM (Zhipu AI)',\n};\n\nconst PROVIDER_TYPE_URLS = {\n local: 'http://localhost:1234',\n openai: 'https://api.openai.com',\n grok: 'https://api.x.ai/v1/chat/completions',\n deepseek: 'https://api.deepseek.com/chat/completions',\n glm: 'https://api.z.ai/api/paas/v4',\n};\n\nconst PROVIDER_TYPE_NAMES = {\n local: 'LM Studio',\n openai: 'OpenAI',\n grok: 'Grok',\n deepseek: 'DeepSeek',\n glm: 'GLM',\n};\n\nfunction renderProvidersList() {\n const container = document.getElementById('providersList');\n if (!_allProviders.length) {\n container.innerHTML = `<p class=\"card-muted\">${t('settings.noProviders')}</p>`;\n return;\n }\n container.innerHTML = _allProviders.map(p => {\n const isActive = p.id === _activeProviderId;\n const typeLabel = PROVIDER_TYPE_LABELS[p.type] || p.type;\n const activeClass = isActive ? ' provider-active' : '';\n const secProv = p.secondary_provider ? _allSecondaryProviders.find(x => x.id === p.secondary_provider) : null;\n const secLabel = secProv ? `<span class=\"provider-secondary-badge\">${t('settings.secondaryShort')}: ${escapeHtml(secProv.name)}</span>` : '';\n return `\n <div class=\"provider-item${activeClass}\">\n <div class=\"provider-info\">\n <div class=\"provider-name\">\n ${isActive ? '✅ ' : ''}${escapeHtml(p.name)}\n <span class=\"provider-type-badge\">${escapeHtml(typeLabel)}</span>\n ${secLabel}\n </div>\n <div class=\"provider-detail\">${escapeHtml(p.base_url)} — model: ${escapeHtml(p.model || 'default')}</div>\n </div>\n <div class=\"provider-actions\">\n ${!isActive ? `<button class=\"btn btn-sm btn-success\" onclick=\"activateProvider('${escapeHtml(p.id)}')\">${t('settings.activate')}</button>` : ''}\n <button class=\"btn btn-sm\" onclick=\"editProvider('${escapeHtml(p.id)}')\">${t('settings.edit')}</button>\n <button class=\"btn btn-sm btn-danger\" onclick=\"deleteProvider('${escapeHtml(p.id)}')\">${t('users.delete')}</button>\n </div>\n </div>`;\n }).join('');\n}\n\nfunction _populateSecondarySelect(excludeId) {\n const sel = document.getElementById('provSecondary');\n sel.innerHTML = `<option value=\"\">${t('settings.noSecondary')}</option>`;\n for (const p of _allSecondaryProviders) {\n sel.innerHTML += `<option value=\"${escapeHtml(p.id)}\">${escapeHtml(p.name)} (${PROVIDER_TYPE_LABELS[p.type] || p.type})</option>`;\n }\n}\n\nfunction openAddProvider() {\n _editingProviderId = null;\n document.getElementById('providerFormTitle').textContent = t('settings.addProvider');\n document.getElementById('provType').value = 'local';\n document.getElementById('provName').value = '';\n document.getElementById('provUrl').value = 'http://localhost:1234';\n document.getElementById('provApiKey').value = '';\n document.getElementById('provModel').value = '';\n document.getElementById('provTimeout').value = 120;\n document.getElementById('provMaxTokens').value = 2048;\n document.getElementById('provTemperature').value = 0.7;\n document.getElementById('provSystemPrompt').value = '';\n document.getElementById('provEcoMode').checked = false;\n _populateSecondarySelect(null);\n document.getElementById('provSecondary').value = '';\n const dl = document.getElementById('provModelList'); if (dl) dl.remove();\n document.getElementById('provTestSection').style.display = 'none';\n document.getElementById('provTestResult').style.display = 'none';\n onProvTypeChange();\n document.getElementById('providersMain').style.display = 'none';\n document.getElementById('providerPage').style.display = '';\n}\n\nfunction editProvider(id) {\n const p = _allProviders.find(x => x.id === id);\n if (!p) return;\n _editingProviderId = id;\n document.getElementById('providerFormTitle').textContent = t('settings.editProvider');\n document.getElementById('provType').value = p.type || 'local';\n document.getElementById('provName').value = p.name || '';\n document.getElementById('provUrl').value = p.base_url || '';\n document.getElementById('provApiKey').value = p.api_key || '';\n document.getElementById('provModel').value = p.model || '';\n document.getElementById('provTimeout').value = p.timeout || 120;\n document.getElementById('provMaxTokens').value = p.max_tokens || 2048;\n document.getElementById('provTemperature').value = p.temperature ?? 0.7;\n document.getElementById('provSystemPrompt').value = p.system_prompt || '';\n document.getElementById('provEcoMode').checked = !!p.eco_mode;\n _populateSecondarySelect(id);\n document.getElementById('provSecondary').value = p.secondary_provider || '';\n const dl2 = document.getElementById('provModelList'); if (dl2) dl2.remove();\n document.getElementById('provTestSection').style.display = '';\n document.getElementById('provTestResult').style.display = 'none';\n onProvTypeChange();\n document.getElementById('providersMain').style.display = 'none';\n document.getElementById('providerPage').style.display = '';\n}\n\nfunction cancelProviderForm() {\n document.getElementById('providerPage').style.display = 'none';\n document.getElementById('providersMain').style.display = '';\n _editingProviderId = null;\n}\n\nasync function testProviderFromPage() {\n if (!_editingProviderId) return;\n const res = document.getElementById('provTestResult');\n res.style.display = '';\n res.textContent = '⏳ Testing...';\n res.style.color = 'var(--muted)';\n try {\n const h = await api('GET', `/api/settings/providers/${encodeURIComponent(_editingProviderId)}/health`);\n const status = h.status || h.provider || 'unknown';\n const model = h.model || '';\n res.textContent = `${status === 'connected' ? '✅' : '❌'} ${status}${model ? ' — ' + model : ''}`;\n res.style.color = status === 'connected' ? 'var(--success)' : 'var(--danger)';\n } catch (e) {\n res.textContent = '❌ ' + e.message;\n res.style.color = 'var(--danger)';\n }\n}\n\nfunction onProvTypeChange() {\n const ptype = document.getElementById('provType').value;\n // Pre-fill URL if empty or still a known default\n const urlField = document.getElementById('provUrl');\n const currentUrl = urlField.value.trim();\n const defaultUrls = Object.values(PROVIDER_TYPE_URLS);\n if (!currentUrl || defaultUrls.includes(currentUrl)) {\n urlField.value = PROVIDER_TYPE_URLS[ptype] || 'http://localhost:1234';\n }\n // Pre-fill name if empty or still a known default\n const nameField = document.getElementById('provName');\n const currentName = nameField.value.trim();\n const defaultNames = Object.values(PROVIDER_TYPE_NAMES);\n if (!currentName || defaultNames.includes(currentName)) {\n nameField.value = PROVIDER_TYPE_NAMES[ptype] || '';\n }\n}\n\nasync function saveProvider() {\n const data = {\n type: document.getElementById('provType').value,\n name: document.getElementById('provName').value.trim(),\n base_url: document.getElementById('provUrl').value.trim(),\n api_key: document.getElementById('provApiKey').value.trim(),\n model: document.getElementById('provModel').value.trim() || 'default',\n timeout: parseInt(document.getElementById('provTimeout').value) || 120,\n max_tokens: parseInt(document.getElementById('provMaxTokens').value) || 2048,\n temperature: parseFloat(document.getElementById('provTemperature').value) || 0.7,\n system_prompt: document.getElementById('provSystemPrompt').value.trim(),\n secondary_provider: document.getElementById('provSecondary').value || '',\n eco_mode: document.getElementById('provEcoMode').checked,\n };\n if (!data.name) { toast(t('settings.providerNameRequired'), true); return; }\n try {\n if (_editingProviderId) {\n await api('PUT', `/api/settings/providers/${encodeURIComponent(_editingProviderId)}`, data);\n toast(t('settings.providerUpdated'));\n } else {\n await api('POST', '/api/settings/providers', data);\n toast(t('settings.providerAdded'));\n }\n cancelProviderForm();\n // Reload providers from server\n const cfg = await api('GET', '/api/settings/');\n _allProviders = cfg.providers || [];\n _activeProviderId = cfg.active_provider || '';\n _allSecondaryProviders = cfg.secondary_providers || [];\n renderProvidersList();\n renderSecondaryProvidersList();\n loadSystemInfo();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function deleteProvider(id) {\n const p = _allProviders.find(x => x.id === id);\n if (!p) return;\n if (!confirm(t('settings.confirmDeleteProvider', { name: p.name }))) return;\n try {\n await api('DELETE', `/api/settings/providers/${encodeURIComponent(id)}`);\n toast(t('settings.providerDeleted'));\n const cfg = await api('GET', '/api/settings/');\n _allProviders = cfg.providers || [];\n _activeProviderId = cfg.active_provider || '';\n _allSecondaryProviders = cfg.secondary_providers || [];\n renderProvidersList();\n renderSecondaryProvidersList();\n loadSystemInfo();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function activateProvider(id) {\n try {\n await api('PUT', `/api/settings/providers/${encodeURIComponent(id)}/activate`);\n _activeProviderId = id;\n renderProvidersList();\n toast(t('settings.providerActivated'));\n loadSystemInfo();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function fetchProviderModels() {\n const baseUrl = document.getElementById('provUrl').value.trim();\n const apiKey = document.getElementById('provApiKey').value.trim();\n const modelInput = document.getElementById('provModel');\n const listId = 'provModelList';\n if (!baseUrl) { toast(t('settings.enterUrl'), true); return; }\n\n async function _populateDatalist(models) {\n let dl = document.getElementById(listId);\n if (!dl) {\n dl = document.createElement('datalist');\n dl.id = listId;\n modelInput.parentElement.appendChild(dl);\n }\n modelInput.setAttribute('list', listId);\n dl.innerHTML = models.map(m => `<option value=\"${escapeHtml(m.id)}\">`).join('');\n if (models.length) {\n if (!modelInput.value || modelInput.value === 'default') modelInput.value = models[0].id;\n toast(t('toast.modelsReloaded'));\n } else {\n toast(t('settings.noModelsFound'), true);\n }\n }\n\n if (_editingProviderId) {\n try {\n const data = await api('GET', `/api/settings/providers/${encodeURIComponent(_editingProviderId)}/models`);\n _populateDatalist(data.models || []);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n } else {\n const tempData = {\n type: document.getElementById('provType').value,\n name: '_temp_model_fetch',\n base_url: baseUrl,\n api_key: apiKey,\n model: 'default',\n timeout: 15,\n max_tokens: 2048,\n temperature: 0.7,\n };\n try {\n const result = await api('POST', '/api/settings/providers', tempData);\n const tempId = result.provider.id;\n try {\n const data = await api('GET', `/api/settings/providers/${encodeURIComponent(tempId)}/models`);\n _populateDatalist(data.models || []);\n } finally {\n await api('DELETE', `/api/settings/providers/${encodeURIComponent(tempId)}`);\n const cfg = await api('GET', '/api/settings/');\n _allProviders = cfg.providers || [];\n _activeProviderId = cfg.active_provider || '';\n }\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n }\n}\n\n// ══════════════════════════════════════════════════\n// SECONDARY PROVIDERS MANAGEMENT\n// ══════════════════════════════════════════════════\n\nfunction renderSecondaryProvidersList() {\n const container = document.getElementById('secondaryProvidersList');\n if (!_allSecondaryProviders.length) {\n container.innerHTML = `<p class=\"card-muted\">${t('settings.noSecondaryProviders')}</p>`;\n return;\n }\n container.innerHTML = _allSecondaryProviders.map(p => {\n const typeLabel = PROVIDER_TYPE_LABELS[p.type] || p.type;\n return `\n <div class=\"provider-item\">\n <div class=\"provider-info\">\n <div class=\"provider-name\">\n 🔗 ${escapeHtml(p.name)}\n <span class=\"provider-type-badge\">${escapeHtml(typeLabel)}</span>\n </div>\n <div class=\"provider-detail\">${escapeHtml(p.base_url)} — model: ${escapeHtml(p.model || 'default')}</div>\n </div>\n <div class=\"provider-actions\">\n <button class=\"btn btn-sm\" onclick=\"editSecondaryProvider('${escapeHtml(p.id)}')\">${t('settings.edit')}</button>\n <button class=\"btn btn-sm btn-danger\" onclick=\"deleteSecondaryProvider('${escapeHtml(p.id)}')\">${t('users.delete')}</button>\n </div>\n </div>`;\n }).join('');\n}\n\nfunction openAddSecondaryProvider() {\n _editingSecProviderId = null;\n document.getElementById('secProviderFormTitle').textContent = t('settings.addSecondaryProvider');\n document.getElementById('secProvType').value = 'local';\n document.getElementById('secProvName').value = '';\n document.getElementById('secProvUrl').value = 'http://localhost:1234';\n document.getElementById('secProvApiKey').value = '';\n document.getElementById('secProvModel').value = '';\n document.getElementById('secProvTimeout').value = 120;\n document.getElementById('secProvMaxTokens').value = 2048;\n document.getElementById('secProvTemperature').value = 0.7;\n document.getElementById('secProvTestSection').style.display = 'none';\n document.getElementById('secProvTestResult').style.display = 'none';\n onSecProvTypeChange();\n document.getElementById('providersMain').style.display = 'none';\n document.getElementById('secProviderPage').style.display = '';\n}\n\nfunction editSecondaryProvider(id) {\n const p = _allSecondaryProviders.find(x => x.id === id);\n if (!p) return;\n _editingSecProviderId = id;\n document.getElementById('secProviderFormTitle').textContent = t('settings.editSecondaryProvider');\n document.getElementById('secProvType').value = p.type || 'local';\n document.getElementById('secProvName').value = p.name || '';\n document.getElementById('secProvUrl').value = p.base_url || '';\n document.getElementById('secProvApiKey').value = p.api_key || '';\n document.getElementById('secProvModel').value = p.model || '';\n document.getElementById('secProvTimeout').value = p.timeout || 120;\n document.getElementById('secProvMaxTokens').value = p.max_tokens || 2048;\n document.getElementById('secProvTemperature').value = p.temperature ?? 0.7;\n document.getElementById('secProvTestSection').style.display = '';\n document.getElementById('secProvTestResult').style.display = 'none';\n onSecProvTypeChange();\n document.getElementById('providersMain').style.display = 'none';\n document.getElementById('secProviderPage').style.display = '';\n}\n\nfunction cancelSecProviderForm() {\n document.getElementById('secProviderPage').style.display = 'none';\n document.getElementById('providerPage').style.display = 'none';\n document.getElementById('providersMain').style.display = '';\n _editingSecProviderId = null;\n}\n\nasync function testSecProviderFromPage() {\n if (!_editingSecProviderId) return;\n const res = document.getElementById('secProvTestResult');\n res.style.display = '';\n res.textContent = '⏳ Testing...';\n res.style.color = 'var(--muted)';\n try {\n const h = await api('GET', `/api/settings/secondary-providers/${encodeURIComponent(_editingSecProviderId)}/health`);\n const status = h.status || 'unknown';\n const model = h.model || '';\n res.textContent = `${status === 'connected' ? '✅' : '❌'} ${status}${model ? ' — ' + model : ''}`;\n res.style.color = status === 'connected' ? 'var(--success)' : 'var(--danger)';\n } catch (e) {\n res.textContent = '❌ ' + e.message;\n res.style.color = 'var(--danger)';\n }\n}\n\nfunction onSecProvTypeChange() {\n const ptype = document.getElementById('secProvType').value;\n const urlField = document.getElementById('secProvUrl');\n const currentUrl = urlField.value.trim();\n const defaultUrls = Object.values(PROVIDER_TYPE_URLS);\n if (!currentUrl || defaultUrls.includes(currentUrl)) {\n urlField.value = PROVIDER_TYPE_URLS[ptype] || 'http://localhost:1234';\n }\n const nameField = document.getElementById('secProvName');\n const currentName = nameField.value.trim();\n const defaultNames = Object.values(PROVIDER_TYPE_NAMES);\n if (!currentName || defaultNames.includes(currentName)) {\n nameField.value = PROVIDER_TYPE_NAMES[ptype] || '';\n }\n}\n\nasync function saveSecondaryProvider() {\n const data = {\n type: document.getElementById('secProvType').value,\n name: document.getElementById('secProvName').value.trim(),\n base_url: document.getElementById('secProvUrl').value.trim(),\n api_key: document.getElementById('secProvApiKey').value.trim(),\n model: document.getElementById('secProvModel').value.trim() || 'default',\n timeout: parseInt(document.getElementById('secProvTimeout').value) || 120,\n max_tokens: parseInt(document.getElementById('secProvMaxTokens').value) || 2048,\n temperature: parseFloat(document.getElementById('secProvTemperature').value) || 0.7,\n };\n if (!data.name) { toast(t('settings.providerNameRequired'), true); return; }\n try {\n if (_editingSecProviderId) {\n await api('PUT', `/api/settings/secondary-providers/${encodeURIComponent(_editingSecProviderId)}`, data);\n toast(t('settings.secProviderUpdated'));\n } else {\n await api('POST', '/api/settings/secondary-providers', data);\n toast(t('settings.secProviderAdded'));\n }\n cancelSecProviderForm();\n const secData = await api('GET', '/api/settings/secondary-providers');\n _allSecondaryProviders = secData.secondary_providers || [];\n renderSecondaryProvidersList();\n loadSystemInfo();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function deleteSecondaryProvider(id) {\n const p = _allSecondaryProviders.find(x => x.id === id);\n if (!p) return;\n if (!confirm(t('settings.confirmDeleteSecProvider', { name: p.name }))) return;\n try {\n await api('DELETE', `/api/settings/secondary-providers/${encodeURIComponent(id)}`);\n toast(t('settings.secProviderDeleted'));\n const secData = await api('GET', '/api/settings/secondary-providers');\n _allSecondaryProviders = secData.secondary_providers || [];\n renderSecondaryProvidersList();\n // Reload primary providers too (cleared references)\n const cfg = await api('GET', '/api/settings/');\n _allProviders = cfg.providers || [];\n renderProvidersList();\n loadSystemInfo();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function fetchSecProviderModels() {\n const baseUrl = document.getElementById('secProvUrl').value.trim();\n const apiKey = document.getElementById('secProvApiKey').value.trim();\n const modelInput = document.getElementById('secProvModel');\n const listId = 'secProvModelList';\n if (!baseUrl) { toast(t('settings.enterUrl'), true); return; }\n\n async function _populateDatalist(models) {\n let dl = document.getElementById(listId);\n if (!dl) {\n dl = document.createElement('datalist');\n dl.id = listId;\n modelInput.parentElement.appendChild(dl);\n }\n modelInput.setAttribute('list', listId);\n dl.innerHTML = models.map(m => `<option value=\"${escapeHtml(m.id)}\">`).join('');\n if (models.length) {\n if (!modelInput.value || modelInput.value === 'default') modelInput.value = models[0].id;\n toast(t('toast.modelsReloaded'));\n } else {\n toast(t('settings.noModelsFound'), true);\n }\n }\n\n if (_editingSecProviderId) {\n try {\n const data = await api('GET', `/api/settings/secondary-providers/${encodeURIComponent(_editingSecProviderId)}/models`);\n _populateDatalist(data.models || []);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n } else {\n // Create temp secondary provider, fetch models, delete it\n const tempData = {\n type: document.getElementById('secProvType').value,\n name: '_temp_sec_model_fetch',\n base_url: baseUrl,\n api_key: apiKey,\n model: 'default',\n timeout: 15,\n max_tokens: 2048,\n temperature: 0.7,\n };\n try {\n const result = await api('POST', '/api/settings/secondary-providers', tempData);\n const tempId = result.provider.id;\n try {\n const data = await api('GET', `/api/settings/secondary-providers/${encodeURIComponent(tempId)}/models`);\n _populateDatalist(data.models || []);\n } finally {\n await api('DELETE', `/api/settings/secondary-providers/${encodeURIComponent(tempId)}`);\n const secData = await api('GET', '/api/settings/secondary-providers');\n _allSecondaryProviders = secData.secondary_providers || [];\n }\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n }\n}\n\n// ══════════════════════════════════════════════════\n// USERS + MEMORY\n// ══════════════════════════════════════════════════\n\nlet _selectedUser = null;\nlet allMemories = [];\nlet _userKeysMap = {}; // username -> api key\nlet _userModalKeyVisible = false;\n\nfunction catLabel(cat) {\n return t('cat.' + cat) || cat;\n}\n\nasync function loadUsersTab() {\n try {\n const [cfg, memUsers] = await Promise.all([\n api('GET', '/api/settings/'),\n api('GET', '/api/memory/users'),\n ]);\n const users = cfg.users || {};\n const apiKeys = users.api_keys || {};\n document.getElementById('defaultUserInput').value = users.default_user || '';\n\n const userMap = {};\n _userKeysMap = {};\n for (const [key, name] of Object.entries(apiKeys)) {\n if (!userMap[name]) userMap[name] = { keys: [], hasMemories: false };\n userMap[name].keys.push(key);\n _userKeysMap[name] = key;\n }\n for (const u of (memUsers.users || [])) {\n if (!userMap[u]) userMap[u] = { keys: [], hasMemories: true };\n else userMap[u].hasMemories = true;\n }\n\n const container = document.getElementById('userCardsList');\n const names = Object.keys(userMap);\n if (names.length === 0) {\n container.innerHTML = `<div class=\"card\"><p class=\"card-muted\">${t('users.noUsers')}</p></div>`;\n return;\n }\n\n container.innerHTML = names.map(name => {\n const info = userMap[name];\n const isSelected = _selectedUser === name;\n const actionsHtml = info.keys.length\n ? `<button class=\"btn btn-sm btn-danger\" onclick=\"event.stopPropagation();deleteUser('${escapeHtml(name)}')\">${t('users.delete')}</button>`\n : '';\n return `\n <div class=\"user-card ${isSelected ? 'selected' : ''}\" onclick=\"selectUser('${escapeHtml(name)}')\">\n <div class=\"user-card-main\">\n <div class=\"user-avatar\">${escapeHtml(name.substring(0,2).toUpperCase())}</div>\n <div class=\"user-info\">\n <div class=\"user-name\">${escapeHtml(name)}</div>\n </div>\n </div>\n <div class=\"user-actions\">${actionsHtml}</div>\n </div>`;\n }).join('');\n } catch (e) {\n toast(t('toast.usersError', { msg: e.message }), true);\n }\n}\n\nfunction openAddUserPage() {\n document.getElementById('newUserName').value = '';\n document.getElementById('usersMain').style.display = 'none';\n document.getElementById('addUserPage').style.display = '';\n applyTranslations();\n setTimeout(() => document.getElementById('newUserName').focus(), 100);\n}\n\nfunction closeAddUserPage() {\n document.getElementById('addUserPage').style.display = 'none';\n document.getElementById('usersMain').style.display = '';\n}\n\nasync function addUser() {\n const name = document.getElementById('newUserName').value.trim();\n if (!name) { toast(t('toast.enterUsername'), true); return; }\n try {\n const result = await api('POST', '/api/settings/users', { username: name });\n document.getElementById('newUserName').value = '';\n closeAddUserPage();\n toast(t('toast.userCreated', { name: result.username }));\n loadUsersTab();\n } catch (e) {\n if (e.message.includes('409')) toast(t('toast.userExists'), true);\n else toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function deleteUser(username) {\n if (!confirm(t('confirm.deleteUser', { name: username }))) return;\n try {\n try { await api('DELETE', `/api/memory/user/${encodeURIComponent(username)}`); } catch(e) { /* no memories */ }\n await api('DELETE', `/api/settings/users/${encodeURIComponent(username)}`);\n toast(t('toast.userDeleted', { name: username }));\n if (_selectedUser === username) closeUserPage();\n loadUsersTab();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function deleteUserFull() {\n if (!_selectedUser) return;\n deleteUser(_selectedUser);\n}\n\nasync function saveDefaultUser() {\n const name = document.getElementById('defaultUserInput').value.trim();\n try {\n await api('PUT', '/api/settings/users/default', { username: name });\n toast(t('toast.defaultUserSaved'));\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction copyUserKey(key) {\n if (navigator.clipboard && window.isSecureContext) {\n navigator.clipboard.writeText(key).then(() => toast(t('toast.apiKeyCopied'))).catch(() => _copyFallback(key, t('toast.apiKeyCopied')));\n } else {\n _copyFallback(key, t('toast.apiKeyCopied'));\n }\n}\n\nasync function selectUser(username) {\n _selectedUser = username;\n _userModalKeyVisible = false;\n document.getElementById('selectedUserName').textContent = username;\n\n // API key section\n const keySection = document.getElementById('userModalKeySection');\n const keyEl = document.getElementById('userModalApiKey');\n const keyToggle = document.getElementById('btnToggleUserKey');\n if (_userKeysMap[username]) {\n keySection.style.display = '';\n keyEl.textContent = '••••••••';\n keyEl.classList.add('api-key-blur');\n keyToggle.textContent = t('info.show');\n } else {\n keySection.style.display = 'none';\n }\n\n document.getElementById('usersMain').style.display = 'none';\n document.getElementById('userDetailPage').style.display = '';\n document.querySelectorAll('.user-card').forEach(c => {\n c.classList.toggle('selected', c.querySelector('.user-name')?.textContent === username);\n });\n await loadStats(username);\n}\n\nfunction toggleUserModalKey() {\n _userModalKeyVisible = !_userModalKeyVisible;\n const el = document.getElementById('userModalApiKey');\n const btn = document.getElementById('btnToggleUserKey');\n const key = _userKeysMap[_selectedUser] || '';\n if (_userModalKeyVisible && key) {\n el.textContent = key;\n el.classList.remove('api-key-blur');\n btn.textContent = t('info.hide') || 'Hide';\n } else {\n el.textContent = '••••••••';\n el.classList.add('api-key-blur');\n btn.textContent = t('info.show');\n }\n}\n\nfunction copyUserModalKey() {\n const key = _userKeysMap[_selectedUser] || '';\n if (!key) return;\n navigator.clipboard.writeText(key).then(() => toast(t('toast.apiKeyCopied'))).catch(() => toast(t('toast.copyFail'), true));\n}\n\nfunction closeUserPage() {\n _selectedUser = null;\n document.getElementById('userDetailPage').style.display = 'none';\n document.getElementById('usersMain').style.display = '';\n document.querySelectorAll('.user-card').forEach(c => c.classList.remove('selected'));\n}\nconst closeUserModal = closeUserPage;\nconst deselectUser = closeUserPage;\n\ndocument.addEventListener('keydown', e => {\n if (e.key === 'Escape') {\n if (document.getElementById('addUserPage').style.display !== 'none') closeAddUserPage();\n else if (document.getElementById('userDetailPage').style.display !== 'none') closeUserPage();\n else if (document.getElementById('addMemoryPage').style.display !== 'none') closeAddMemoryPage();\n else if (document.getElementById('skillEditorPage').style.display !== 'none') closeSkillEditor();\n else if (document.getElementById('convDetailPage').style.display !== 'none') closeConvPage();\n else if (document.getElementById('secProviderPage').style.display !== 'none') cancelSecProviderForm();\n else if (document.getElementById('providerPage').style.display !== 'none') cancelProviderForm();\n }\n});\n\n// ── Memory functions ──\n\nasync function loadStats(userId) {\n try {\n const stats = await api('GET', `/api/memory/stats/${encodeURIComponent(userId)}`);\n const grid = document.getElementById('statsGrid');\n let html = `<div class=\"stat-card\"><div class=\"num\">${stats.total}</div><div class=\"lbl\">${t('modal.total')}</div></div>`;\n for (const [cat, count] of Object.entries(stats.by_category || {})) {\n html += `<div class=\"stat-card\"><div class=\"num\">${count}</div><div class=\"lbl\">${catLabel(cat)}</div></div>`;\n }\n grid.innerHTML = html;\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function loadMemories(userId) {\n if (!userId) userId = _selectedUser;\n if (!userId) return;\n try {\n const data = await api('GET', `/api/memory/${encodeURIComponent(userId)}?limit=200`);\n allMemories = data.memories || [];\n renderMemories(allMemories);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction renderMemories(memories) {\n const list = document.getElementById('memList');\n if (!memories.length) {\n list.innerHTML = `<p style=\"color:var(--muted);font-size:.9rem\">${t('status.noMemory')}</p>`;\n updateMemBulkBar();\n return;\n }\n list.innerHTML = memories.map(m => {\n const stars = '★'.repeat(m.importance) + '☆'.repeat(5 - m.importance);\n const date = new Date(m.created_at * 1000).toLocaleDateString(currentLang === 'ro' ? 'ro-RO' : 'en-US');\n const accessed = m.access_count > 0 ? t('status.accessed', { count: m.access_count }) : t('status.notAccessed');\n return `\n <div class=\"mem-item\" data-cat=\"${m.category}\">\n <input type=\"checkbox\" class=\"mem-cb\" value=\"${m.id}\" onclick=\"updateMemBulkBar()\" style=\"width:18px;height:18px;cursor:pointer;flex-shrink:0;margin-top:2px\">\n <div class=\"mem-content\">\n <span class=\"mem-badge cat-${m.category}\">${catLabel(m.category)}</span>\n <span class=\"importance-stars\" title=\"${m.importance}/5\">${stars}</span>\n <div style=\"margin-top:6px\">${escapeHtml(m.content)}</div>\n <div class=\"mem-meta\">\n <span>${date}</span>\n <span>${accessed}</span>\n <span>${escapeHtml(m.keywords || '-')}</span>\n <span>${m.source}</span>\n </div>\n </div>\n <button class=\"btn btn-danger btn-sm\" onclick=\"deleteMemory(${m.id})\">${t('users.delete')}</button>\n </div>`;\n }).join('');\n updateMemBulkBar();\n}\n\nfunction updateMemBulkBar() {\n const checked = document.querySelectorAll('.mem-cb:checked');\n const delBtn = document.getElementById('memBulkDeleteBtn');\n const selBtn = document.getElementById('memSelectAllBtn');\n const countEl = document.getElementById('memSelectedCount');\n const hasMemories = document.querySelectorAll('.mem-cb').length > 0;\n if (hasMemories) {\n selBtn.style.display = '';\n if (checked.length > 0) {\n delBtn.style.display = '';\n countEl.style.display = '';\n countEl.textContent = `${checked.length} ${t('modal.selected')}`;\n } else {\n delBtn.style.display = 'none';\n countEl.style.display = 'none';\n }\n } else {\n delBtn.style.display = 'none';\n selBtn.style.display = 'none';\n countEl.style.display = 'none';\n }\n}\n\nfunction toggleMemSelectAll() {\n const cbs = document.querySelectorAll('.mem-cb');\n const allChecked = [...cbs].every(cb => cb.checked);\n cbs.forEach(cb => cb.checked = !allChecked);\n updateMemBulkBar();\n}\n\nasync function bulkDeleteMemories() {\n const checked = document.querySelectorAll('.mem-cb:checked');\n if (!checked.length) return;\n if (!confirm(t('confirm.bulkDeleteMemories', { count: checked.length }))) return;\n const ids = [...checked].map(cb => parseInt(cb.value));\n try {\n await api('POST', '/api/memory/bulk-delete', { ids });\n toast(t('toast.memoriesBulkDeleted', { count: ids.length }));\n if (_selectedUser) await Promise.all([loadStats(_selectedUser), loadMemories(_selectedUser)]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction filterMemories(cat, btn) {\n document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n renderMemories(cat === 'all' ? allMemories : allMemories.filter(m => m.category === cat));\n}\n\nasync function addMemory() {\n if (!_selectedUser) { toast(t('toast.selectUser'), true); return; }\n const content = document.getElementById('newMemContent').value;\n if (!content) { toast(t('toast.writeContent'), true); return; }\n try {\n await api('POST', '/api/memory/', {\n user_id: _selectedUser,\n content,\n category: document.getElementById('newMemCat').value,\n keywords: document.getElementById('newMemKeywords').value,\n importance: parseInt(document.getElementById('newMemImp').value),\n });\n document.getElementById('newMemContent').value = '';\n document.getElementById('newMemKeywords').value = '';\n toast(t('toast.memoryAdded'));\n await Promise.all([loadStats(_selectedUser), loadMemories(_selectedUser)]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function deleteMemory(id) {\n try {\n await api('DELETE', `/api/memory/${id}`);\n toast(t('toast.memoryDeleted'));\n if (_selectedUser) await Promise.all([loadStats(_selectedUser), loadMemories(_selectedUser)]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function clearUserMemories() {\n if (!_selectedUser) return;\n if (!confirm(t('confirm.deleteAllMemories', { name: _selectedUser }))) return;\n try {\n await api('DELETE', `/api/memory/user/${encodeURIComponent(_selectedUser)}`);\n toast(t('toast.memoriesDeleted'));\n await Promise.all([loadStats(_selectedUser), loadMemories(_selectedUser)]);\n loadUsersTab();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function consolidateMemories() {\n if (!_selectedUser) { toast(t('toast.selectUser'), true); return; }\n toast(t('toast.consolidating'));\n try {\n await api('POST', `/api/memory/consolidate/${encodeURIComponent(_selectedUser)}`);\n toast(t('toast.consolidateComplete'));\n await Promise.all([loadStats(_selectedUser), loadMemories(_selectedUser)]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\n// ══════════════════════════════════════════════════\n// MEMORIES TAB\n// ══════════════════════════════════════════════════\n\nlet _memTabUser = '';\nlet _memTabAll = [];\nlet _memTabFiltered = [];\nlet _memTabPage = 1;\nconst _memTabPerPage = 5;\n\nasync function refreshMemTabUsers() {\n const select = document.getElementById('memTabUserSelect');\n const prev = select.value;\n try {\n const [cfg, memData] = await Promise.all([\n api('GET', '/api/settings/'),\n api('GET', '/api/memory/users'),\n ]);\n const userSet = new Set();\n const apiKeys = (cfg.users || {}).api_keys || {};\n for (const name of Object.values(apiKeys)) userSet.add(name);\n for (const u of (memData.users || [])) userSet.add(u);\n\n select.innerHTML = `<option value=\"\">${t('conv.choose')}</option>`;\n for (const name of [...userSet].sort()) {\n const opt = document.createElement('option');\n opt.value = name;\n opt.textContent = name;\n select.appendChild(opt);\n }\n if (prev && userSet.has(prev)) select.value = prev;\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function loadMemoriesTab() {\n _memTabUser = document.getElementById('memTabUserSelect').value;\n const statsCard = document.getElementById('memTabStatsCard');\n const addBtn = document.getElementById('memTabAddBtn');\n const listCard = document.getElementById('memTabListCard');\n if (!_memTabUser) {\n statsCard.style.display = 'none';\n addBtn.style.display = 'none';\n listCard.style.display = 'none';\n return;\n }\n statsCard.style.display = '';\n addBtn.style.display = '';\n listCard.style.display = '';\n await Promise.all([loadMemTabStats(), loadMemTabItems()]);\n}\n\nasync function loadMemTabStats() {\n if (!_memTabUser) return;\n try {\n const stats = await api('GET', `/api/memory/stats/${encodeURIComponent(_memTabUser)}`);\n const grid = document.getElementById('memTabStatsGrid');\n let html = `<div class=\"stat-card\"><div class=\"num\">${stats.total}</div><div class=\"lbl\">${t('modal.total')}</div></div>`;\n for (const [cat, count] of Object.entries(stats.by_category || {})) {\n html += `<div class=\"stat-card\"><div class=\"num\">${count}</div><div class=\"lbl\">${catLabel(cat)}</div></div>`;\n }\n grid.innerHTML = html;\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function loadMemTabItems() {\n if (!_memTabUser) return;\n try {\n const data = await api('GET', `/api/memory/${encodeURIComponent(_memTabUser)}?limit=200`);\n _memTabAll = data.memories || [];\n renderMemTabItems(_memTabAll);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction renderMemTabItems(memories) {\n _memTabFiltered = memories;\n const list = document.getElementById('memTabList');\n const pagEl = document.getElementById('memTabPagination');\n if (!memories.length) {\n list.innerHTML = `<p style=\"color:var(--muted);font-size:.9rem\">${t('status.noMemory')}</p>`;\n pagEl.innerHTML = '';\n updateMemTabBulkBar();\n return;\n }\n const totalPages = Math.ceil(memories.length / _memTabPerPage);\n if (_memTabPage > totalPages) _memTabPage = totalPages;\n if (_memTabPage < 1) _memTabPage = 1;\n const start = (_memTabPage - 1) * _memTabPerPage;\n const page = memories.slice(start, start + _memTabPerPage);\n list.innerHTML = page.map(m => {\n const stars = '★'.repeat(m.importance) + '☆'.repeat(5 - m.importance);\n const date = new Date(m.created_at * 1000).toLocaleDateString(currentLang === 'ro' ? 'ro-RO' : 'en-US');\n const accessed = m.access_count > 0 ? t('status.accessed', { count: m.access_count }) : t('status.notAccessed');\n return `\n <div class=\"mem-item\" data-cat=\"${m.category}\">\n <input type=\"checkbox\" class=\"mem-tab-cb\" value=\"${m.id}\" onclick=\"updateMemTabBulkBar()\" style=\"width:18px;height:18px;cursor:pointer;flex-shrink:0;margin-top:2px\">\n <div class=\"mem-content\">\n <span class=\"mem-badge cat-${m.category}\">${catLabel(m.category)}</span>\n <span class=\"importance-stars\" title=\"${m.importance}/5\">${stars}</span>\n <div style=\"margin-top:6px\">${escapeHtml(m.content)}</div>\n <div class=\"mem-meta\">\n <span>${date}</span>\n <span>${accessed}</span>\n <span>${escapeHtml(m.keywords || '-')}</span>\n <span>${m.source}</span>\n </div>\n </div>\n </div>`;\n }).join('');\n // Pagination controls\n if (totalPages > 1) {\n let pag = `<button onclick=\"memTabGoPage(${_memTabPage - 1})\" ${_memTabPage === 1 ? 'disabled' : ''}>&lsaquo;</button>`;\n for (let i = 1; i <= totalPages; i++) {\n pag += `<button onclick=\"memTabGoPage(${i})\" class=\"${i === _memTabPage ? 'active' : ''}\">${i}</button>`;\n }\n pag += `<button onclick=\"memTabGoPage(${_memTabPage + 1})\" ${_memTabPage === totalPages ? 'disabled' : ''}>&rsaquo;</button>`;\n pagEl.innerHTML = pag;\n } else {\n pagEl.innerHTML = '';\n }\n updateMemTabBulkBar();\n}\n\nfunction memTabGoPage(page) {\n _memTabPage = page;\n renderMemTabItems(_memTabFiltered);\n}\n\nfunction updateMemTabBulkBar() {\n const checked = document.querySelectorAll('.mem-tab-cb:checked');\n const delBtn = document.getElementById('memTabBulkDeleteBtn');\n const selBtn = document.getElementById('memTabSelectAllBtn');\n const countEl = document.getElementById('memTabSelectedCount');\n const hasMemories = document.querySelectorAll('.mem-tab-cb').length > 0;\n if (hasMemories) {\n selBtn.style.display = '';\n if (checked.length > 0) {\n delBtn.style.display = '';\n countEl.style.display = '';\n countEl.textContent = `${checked.length} ${t('modal.selected')}`;\n } else {\n delBtn.style.display = 'none';\n countEl.style.display = 'none';\n }\n } else {\n delBtn.style.display = 'none';\n selBtn.style.display = 'none';\n countEl.style.display = 'none';\n }\n}\n\nfunction toggleMemTabSelectAll() {\n const cbs = document.querySelectorAll('.mem-tab-cb');\n const allChecked = [...cbs].every(cb => cb.checked);\n cbs.forEach(cb => cb.checked = !allChecked);\n updateMemTabBulkBar();\n}\n\nasync function bulkDeleteMemoriesTab() {\n const checked = document.querySelectorAll('.mem-tab-cb:checked');\n if (!checked.length) return;\n if (!confirm(t('confirm.bulkDeleteMemories', { count: checked.length }))) return;\n const ids = [...checked].map(cb => parseInt(cb.value));\n try {\n await api('POST', '/api/memory/bulk-delete', { ids });\n toast(t('toast.memoriesBulkDeleted', { count: ids.length }));\n await Promise.all([loadMemTabStats(), loadMemTabItems()]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction filterMemoriesTab(cat, btn) {\n document.querySelectorAll('.mem-tab-filter').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n _memTabPage = 1;\n renderMemTabItems(cat === 'all' ? _memTabAll : _memTabAll.filter(m => m.category === cat));\n}\n\nasync function addMemoryTab() {\n if (!_memTabUser) { toast(t('toast.selectUser'), true); return; }\n const content = document.getElementById('memTabNewContent').value;\n if (!content) { toast(t('toast.writeContent'), true); return; }\n try {\n await api('POST', '/api/memory/', {\n user_id: _memTabUser,\n content,\n category: document.getElementById('memTabNewCat').value,\n keywords: document.getElementById('memTabNewKeywords').value,\n importance: parseInt(document.getElementById('memTabNewImp').value),\n });\n document.getElementById('memTabNewContent').value = '';\n document.getElementById('memTabNewKeywords').value = '';\n closeAddMemoryPage();\n toast(t('toast.memoryAdded'));\n await Promise.all([loadMemTabStats(), loadMemTabItems()]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction openAddMemoryPage() {\n document.getElementById('memTabNewCat').value = 'facts';\n document.getElementById('memTabNewImp').value = 3;\n document.getElementById('memTabNewContent').value = '';\n document.getElementById('memTabNewKeywords').value = '';\n document.getElementById('memoriesMain').style.display = 'none';\n document.getElementById('addMemoryPage').style.display = '';\n applyTranslations();\n}\n\nfunction closeAddMemoryPage() {\n document.getElementById('addMemoryPage').style.display = 'none';\n document.getElementById('memoriesMain').style.display = '';\n}\n\nasync function deleteMemoryTab(id) {\n try {\n await api('DELETE', `/api/memory/${id}`);\n toast(t('toast.memoryDeleted'));\n await Promise.all([loadMemTabStats(), loadMemTabItems()]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function clearMemoriesTab() {\n if (!_memTabUser) return;\n if (!confirm(t('confirm.deleteAllMemories', { name: _memTabUser }))) return;\n try {\n await api('DELETE', `/api/memory/user/${encodeURIComponent(_memTabUser)}`);\n toast(t('toast.memoriesDeleted'));\n await Promise.all([loadMemTabStats(), loadMemTabItems()]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function consolidateMemoriesTab() {\n if (!_memTabUser) { toast(t('toast.selectUser'), true); return; }\n toast(t('toast.consolidating'));\n try {\n await api('POST', `/api/memory/consolidate/${encodeURIComponent(_memTabUser)}`);\n toast(t('toast.consolidateComplete'));\n await Promise.all([loadMemTabStats(), loadMemTabItems()]);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function restartServer() {\n if (!confirm(t('confirm.restart'))) return;\n try {\n await api('POST', '/api/settings/restart');\n toast(t('toast.serverRestarting'));\n setTimeout(() => {\n const check = setInterval(async () => {\n try {\n await fetch('/api/settings/health');\n clearInterval(check);\n location.reload();\n } catch(e) { /* still restarting */ }\n }, 1500);\n }, 2000);\n } catch (e) {\n toast(t('toast.restartError', { msg: e.message }), true);\n }\n}\n\n// ══════════════════════════════════════════════════\n// BACKUP / RESTORE\n// ══════════════════════════════════════════════════\n\nfunction downloadBackup() {\n const a = document.createElement('a');\n a.href = API + '/api/settings/backup';\n a.download = 'hassai_backup.db';\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n toast(t('toast.backupDownloaded'));\n}\n\nasync function uploadRestore(input) {\n const file = input.files[0];\n if (!file) return;\n if (!confirm(t('confirm.restore'))) {\n input.value = '';\n return;\n }\n const formData = new FormData();\n formData.append('file', file);\n try {\n const resp = await fetch(API + '/api/settings/restore/upload', {\n method: 'POST',\n body: formData,\n });\n if (!resp.ok) {\n const err = await resp.json().catch(() => ({}));\n throw new Error(err.detail || `HTTP ${resp.status}`);\n }\n toast(t('toast.dbRestored'));\n setTimeout(() => location.reload(), 1500);\n } catch (e) {\n toast(t('toast.restoreError', { msg: e.message }), true);\n }\n input.value = '';\n}\n\n// ══════════════════════════════════════════════════\n// CONVERSATIONS TAB\n// ══════════════════════════════════════════════════\n\nlet _convUserId = '';\nlet _convSessionId = '';\n\nasync function refreshConvUsers() {\n const select = document.getElementById('convUserSelect');\n const prev = select.value;\n try {\n const [cfg, memData] = await Promise.all([\n api('GET', '/api/settings/'),\n api('GET', '/api/memory/users'),\n ]);\n const userSet = new Set();\n const apiKeys = (cfg.users || {}).api_keys || {};\n for (const name of Object.values(apiKeys)) userSet.add(name);\n for (const u of (memData.users || [])) userSet.add(u);\n\n select.innerHTML = `<option value=\"\">${t('conv.choose')}</option>`;\n for (const name of [...userSet].sort()) {\n const opt = document.createElement('option');\n opt.value = name;\n opt.textContent = name;\n select.appendChild(opt);\n }\n if (prev && userSet.has(prev)) select.value = prev;\n toast(t('toast.usersReloaded'));\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function loadConversations() {\n const userId = document.getElementById('convUserSelect').value;\n const sessCard = document.getElementById('convSessionsCard');\n\n if (!userId) {\n sessCard.style.display = 'none';\n return;\n }\n\n const locale = currentLang === 'ro' ? 'ro-RO' : 'en-US';\n try {\n const data = await api('GET', `/api/settings/conversations/${encodeURIComponent(userId)}`);\n const sessions = data.sessions || [];\n sessCard.style.display = '';\n\n const list = document.getElementById('convSessionsList');\n if (!sessions.length) {\n list.innerHTML = `<p class=\"card-muted\">${t('conv.noConversations')}</p>`;\n return;\n }\n\n list.innerHTML = sessions.map(s => {\n const started = new Date(s.started_at * 1000);\n const last = new Date(s.last_at * 1000);\n const dateStr = started.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });\n const timeStr = started.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });\n const lastTimeStr = last.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });\n const duration = Math.round((s.last_at - s.started_at) / 60);\n const durationStr = duration < 1 ? '<1 min' : duration < 60 ? `${duration} min` : `${Math.floor(duration/60)}h ${duration%60}m`;\n return `\n <div class=\"conv-session-item\" style=\"display:flex;align-items:center;gap:10px\">\n <input type=\"checkbox\" class=\"conv-session-cb\" value=\"${escapeHtml(s.session_id)}\" onclick=\"event.stopPropagation();updateConvBulkBar()\" style=\"width:18px;height:18px;cursor:pointer;flex-shrink:0\">\n <div style=\"flex:1;cursor:pointer\" onclick=\"openConvSession('${escapeHtml(userId)}','${escapeHtml(s.session_id)}')\">\n <div class=\"conv-session-date\">${dateStr} &nbsp; ${timeStr} — ${lastTimeStr}</div>\n <div class=\"conv-session-meta\">\n <span>${s.message_count} ${t('conv.messages')}</span>\n <span>${durationStr}</span>\n </div>\n </div>\n <div class=\"conv-session-arrow\" onclick=\"openConvSession('${escapeHtml(userId)}','${escapeHtml(s.session_id)}')\" style=\"cursor:pointer\">›</div>\n </div>`;\n }).join('');\n updateConvBulkBar();\n } catch (e) {\n toast(t('toast.convsError', { msg: e.message }), true);\n }\n}\n\nasync function openConvSession(userId, sessionId) {\n _convUserId = userId;\n _convSessionId = sessionId;\n\n const body = document.getElementById('convPageMsgList');\n body.innerHTML = `<p class=\"card-muted\" style=\"text-align:center;padding:40px 0\">${t('conv.loadingMessages')}</p>`;\n\n document.getElementById('convMain').style.display = 'none';\n document.getElementById('convDetailPage').style.display = '';\n\n const locale = currentLang === 'ro' ? 'ro-RO' : 'en-US';\n try {\n const data = await api('GET', `/api/settings/conversations/${encodeURIComponent(userId)}/${encodeURIComponent(sessionId)}`);\n const messages = data.messages || [];\n\n if (messages.length) {\n const first = new Date(messages[0].created_at * 1000);\n const last = new Date(messages[messages.length - 1].created_at * 1000);\n document.getElementById('convPageTitle').textContent =\n first.toLocaleDateString(locale, { day: '2-digit', month: 'long', year: 'numeric' });\n document.getElementById('convPageSubtitle').textContent =\n `${messages.length} ${t('conv.messages')} · ${first.toLocaleTimeString(locale, {hour:'2-digit',minute:'2-digit'})} — ${last.toLocaleTimeString(locale, {hour:'2-digit',minute:'2-digit'})}`;\n } else {\n document.getElementById('convPageTitle').textContent = t('conv.conversation');\n document.getElementById('convPageSubtitle').textContent = t('conv.noMessages');\n }\n\n if (!messages.length) {\n body.innerHTML = `<p class=\"card-muted\" style=\"text-align:center;padding:40px 0\">${t('conv.noMessages')}</p>`;\n return;\n }\n\n body.innerHTML = messages.map(m => {\n const time = new Date(m.created_at * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' });\n const roleLabel = m.role === 'user' ? t('status.userRole') : m.role === 'assistant' ? t('status.assistantRole') : m.role;\n const content = escapeHtml(m.content).replace(/\\n/g, '<br>');\n return `\n <div class=\"conv-msg conv-msg-${m.role}\">\n <div class=\"conv-msg-header\">\n <span class=\"conv-msg-role\">${roleLabel}</span>\n <span class=\"conv-msg-time\">${time}</span>\n </div>\n <div class=\"conv-msg-content\">${content}</div>\n </div>`;\n }).join('');\n } catch (e) {\n body.innerHTML = `<p class=\"card-muted\" style=\"text-align:center;padding:40px 0;color:var(--danger)\">${t('toast.error', { msg: escapeHtml(e.message) })}</p>`;\n }\n}\n\nfunction updateConvBulkBar() {\n const checked = document.querySelectorAll('.conv-session-cb:checked');\n const bar = document.getElementById('convBulkBar');\n const count = document.getElementById('convSelectedCount');\n if (checked.length > 0) {\n bar.style.display = 'flex';\n count.textContent = `${checked.length} ${t('conv.selected')}`;\n } else {\n bar.style.display = 'none';\n }\n}\n\nfunction toggleConvSelectAll() {\n const cbs = document.querySelectorAll('.conv-session-cb');\n const allChecked = [...cbs].every(cb => cb.checked);\n cbs.forEach(cb => cb.checked = !allChecked);\n updateConvBulkBar();\n}\n\nasync function bulkDeleteSessions() {\n const checked = document.querySelectorAll('.conv-session-cb:checked');\n if (!checked.length) return;\n const userId = document.getElementById('convUserSelect').value;\n if (!userId) return;\n if (!confirm(t('confirm.bulkDeleteSessions', { count: checked.length }))) return;\n const sessionIds = [...checked].map(cb => cb.value);\n try {\n await api('POST', `/api/settings/conversations/${encodeURIComponent(userId)}/bulk-delete`, { session_ids: sessionIds });\n toast(t('toast.sessionsDeleted', { count: sessionIds.length }));\n loadConversations();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nfunction closeConvPage() {\n document.getElementById('convDetailPage').style.display = 'none';\n document.getElementById('convMain').style.display = '';\n _convUserId = '';\n _convSessionId = '';\n}\nconst closeConvModal = closeConvPage;\n\nasync function deleteCurrentSession() {\n if (!_convUserId || !_convSessionId) return;\n if (!confirm(t('confirm.deleteSession'))) return;\n try {\n await api('DELETE', `/api/settings/conversations/${encodeURIComponent(_convUserId)}/${encodeURIComponent(_convSessionId)}`);\n toast(t('toast.sessionDeleted'));\n closeConvPage();\n loadConversations();\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\n// ── Init ──\nloadSystemInfo();\nloadSettings();\nrefreshConvUsers();\nloadUsersTab();\n\n// ══════════════════════════════════════════════════\n// LOGS TAB\n// ══════════════════════════════════════════════════\n\nlet _logsAutoTimer = null;\n\nasync function loadLogs() {\n const level = document.getElementById('logLevel').value;\n const search = document.getElementById('logSearch').value.trim();\n const pre = document.getElementById('logsOutput');\n try {\n const params = new URLSearchParams({ limit: 500, level });\n if (search) params.set('search', search);\n const resp = await fetch(`${API}/api/logs?${params}`);\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const logs = await resp.json();\n if (!logs.length) {\n pre.innerHTML = `<span class=\"log-muted\">${t('logs.noLogs')}</span>`;\n return;\n }\n pre.innerHTML = logs.map(e => {\n const lvl = escapeHtml(e.level);\n const name = escapeHtml(e.name);\n const msg = escapeHtml(e.msg);\n return `<span class=\"log-line\"><span class=\"log-ts\">${escapeHtml(e.ts)}</span> <span class=\"log-level-${lvl}\">${lvl.padEnd(8)}</span> <span class=\"log-name\">[${name}]</span> ${msg}</span>`;\n }).join('\\n');\n // Auto-scroll to bottom\n pre.scrollTop = pre.scrollHeight;\n } catch (e) {\n pre.innerHTML = `<span class=\"log-level-ERROR\">${t('logs.loadError', { msg: e.message })}</span>`;\n }\n}\n\nfunction clearLogsView() {\n document.getElementById('logsOutput').innerHTML = `<span class=\"log-muted\">${t('logs.cleared')}</span>`;\n}\n\nfunction _startLogsAutoRefresh() {\n _stopLogsAutoRefresh();\n if (document.getElementById('logAutoRefresh')?.checked) {\n _logsAutoTimer = setInterval(loadLogs, 3000);\n }\n}\n\nfunction _stopLogsAutoRefresh() {\n if (_logsAutoTimer) { clearInterval(_logsAutoTimer); _logsAutoTimer = null; }\n}\n\n// Auto-refresh control\ndocument.getElementById('logAutoRefresh')?.addEventListener('change', () => {\n if (document.getElementById('logAutoRefresh').checked) _startLogsAutoRefresh();\n else _stopLogsAutoRefresh();\n});\n\n// Search on Enter\ndocument.getElementById('logSearch')?.addEventListener('keydown', e => {\n if (e.key === 'Enter') loadLogs();\n});\n\n// Start/stop auto-refresh when switching tabs\ndocument.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.dataset.panel === 'logs') {\n loadLogs();\n _startLogsAutoRefresh();\n } else {\n _stopLogsAutoRefresh();\n }\n if (tab.dataset.panel === 'statistics') {\n loadUsageStats();\n }\n if (tab.dataset.panel === 'skills') {\n loadSkills();\n }\n if (tab.dataset.panel === 'conversations') {\n refreshConvUsers();\n }\n if (tab.dataset.panel === 'memories') {\n refreshMemTabUsers();\n }\n });\n});\n\n\n// ══════════════════════════════════════════════════\n// SKILLS TAB\n// ══════════════════════════════════════════════════\n\nlet _skillEditing = null; // null = creating, string = skill name being edited\n\n// ── Code Editor helpers ──\nfunction _syntaxHighlight(code) {\n const esc = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\n return esc\n .replace(/(#.*?)$/gm, '<span class=\"syn-comment\">$1</span>')\n .replace(/(&quot;{3}|'{3})([\\s\\S]*?)\\1/g, '<span class=\"syn-str\">$1$2$1</span>')\n .replace(/([\"'])(?:(?!\\1|\\\\).|\\\\.)*?\\1/g, '<span class=\"syn-str\">$&</span>')\n .replace(/\\b(def|class|if|elif|else|for|while|return|import|from|as|try|except|finally|with|raise|yield|pass|break|continue|and|or|not|in|is|lambda|async|await|global|nonlocal)\\b/g, '<span class=\"syn-kw\">$1</span>')\n .replace(/\\b(print|len|range|int|str|float|list|dict|set|tuple|bool|type|isinstance|getattr|setattr|hasattr|open|super|None|True|False)\\b/g, '<span class=\"syn-builtin\">$1</span>')\n .replace(/\\b(self)\\b/g, '<span class=\"syn-self\">$1</span>')\n .replace(/@[\\w.]+/g, '<span class=\"syn-decorator\">$&</span>')\n .replace(/\\b(\\d+\\.?\\d*)\\b/g, '<span class=\"syn-number\">$1</span>')\n .replace(/\\b([a-zA-Z_]\\w*)\\s*(?=\\()/g, '<span class=\"syn-func\">$1</span>');\n}\n\nfunction _updateCodeEditor() {\n const ta = document.getElementById('skillEditSource');\n const hl = document.getElementById('skillHighlight');\n const ln = document.getElementById('skillLineNumbers');\n if (!ta || !hl || !ln) return;\n\n // Highlight\n hl.innerHTML = _syntaxHighlight(ta.value) + '\\n';\n\n // Line numbers\n const lines = ta.value.split('\\n').length;\n let nums = '';\n for (let i = 1; i <= lines; i++) nums += `<span>${i}</span>`;\n ln.innerHTML = nums;\n\n // Resize textarea to fit content (no internal scroll)\n ta.style.height = 'auto';\n ta.style.height = Math.max(360, ta.scrollHeight) + 'px';\n}\n\nfunction _syncEditorScroll() {\n const container = document.getElementById('skillEditSource').parentElement;\n const hl = document.getElementById('skillHighlight');\n const ln = document.getElementById('skillLineNumbers');\n if (!container || !hl || !ln) return;\n ln.scrollTop = container.scrollTop;\n}\n\nfunction _initCodeEditor() {\n const ta = document.getElementById('skillEditSource');\n if (!ta || ta._editorInit) return;\n ta._editorInit = true;\n ta.addEventListener('input', _updateCodeEditor);\n // Scroll sync on the container, not textarea\n ta.parentElement.addEventListener('scroll', _syncEditorScroll);\n ta.addEventListener('keydown', function(e) {\n if (e.key === 'Tab') {\n e.preventDefault();\n const start = this.selectionStart;\n const end = this.selectionEnd;\n this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);\n this.selectionStart = this.selectionEnd = start + 4;\n _updateCodeEditor();\n }\n });\n _updateCodeEditor();\n}\n\nasync function loadSkills() {\n const el = document.getElementById('skillsList');\n if (!el) return;\n try {\n const list = await api('GET', '/api/skills/');\n if (!list.length) {\n el.innerHTML = '<div class=\"card\"><p class=\"card-muted\" data-i18n=\"skills.noSkills\">No skills installed. Click \"+ New Skill\" to create one.</p></div>';\n applyTranslations();\n return;\n }\n el.innerHTML = list.map(s => `\n <div class=\"card skill-card\" style=\"display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;padding:14px 18px\">\n <div style=\"flex:1;min-width:200px\">\n <div style=\"display:flex;align-items:center;gap:8px\">\n <strong>${escapeHtml(s.name)}</strong>\n ${s.generated ? '<span class=\"badge badge-sm\" style=\"background:#00b894;color:#fff\">generated</span>' : '<span class=\"badge badge-sm\" style=\"background:#6c5ce7;color:#fff\">built-in</span>'}\n ${s.disabled ? '<span class=\"badge badge-sm\" style=\"background:#d63031;color:#fff\">disabled</span>' : ''}\n </div>\n <div style=\"font-size:.82rem;color:var(--muted);margin-top:2px\">${escapeHtml(s.description)}</div>\n </div>\n <div style=\"display:flex;gap:6px;flex-shrink:0\">\n <button class=\"btn btn-sm btn-primary\" onclick=\"editSkill('${escapeHtml(s.name)}')\" data-i18n=\"skills.edit\">Edit</button>\n <button class=\"btn btn-sm\" onclick=\"toggleSkill('${escapeHtml(s.name)}')\">${s.disabled ? '▶ Enable' : '⏸ Disable'}</button>\n ${s.generated ? `<button class=\"btn btn-sm btn-danger\" onclick=\"deleteSkill('${escapeHtml(s.name)}')\" data-i18n=\"skills.delete\">Delete</button>` : ''}\n </div>\n </div>\n `).join('');\n applyTranslations();\n } catch (e) {\n el.innerHTML = `<div class=\"card\"><p style=\"color:var(--danger)\">Error loading skills: ${escapeHtml(e.message)}</p></div>`;\n }\n}\n\nasync function openCreateSkill() {\n _skillEditing = null;\n document.getElementById('skillEditorTitle').textContent = t('skills.createSkill') || 'Create Skill';\n document.getElementById('skillEditorSubtitle').textContent = t('skills.createDesc') || 'Create a new custom skill';\n document.getElementById('skillNameSection').style.display = '';\n document.getElementById('skillEditName').value = '';\n document.getElementById('skillTestOutput').style.display = 'none';\n\n // Load template\n try {\n const tpl = await api('GET', '/api/skills/template');\n document.getElementById('skillEditSource').value = tpl.source || '';\n } catch {\n document.getElementById('skillEditSource').value = '';\n }\n document.getElementById('skillsMain').style.display = 'none';\n document.getElementById('skillEditorPage').style.display = '';\n _initCodeEditor();\n _updateCodeEditor();\n}\n\nasync function editSkill(name) {\n _skillEditing = name;\n document.getElementById('skillEditorTitle').textContent = t('skills.editSkill') || 'Edit Skill';\n document.getElementById('skillEditorSubtitle').textContent = name;\n document.getElementById('skillNameSection').style.display = 'none';\n document.getElementById('skillTestOutput').style.display = 'none';\n\n try {\n const data = await api('GET', `/api/skills/${encodeURIComponent(name)}`);\n document.getElementById('skillEditSource').value = data.source || '';\n } catch (e) {\n toast('Error: ' + e.message, true);\n return;\n }\n document.getElementById('skillsMain').style.display = 'none';\n document.getElementById('skillEditorPage').style.display = '';\n _initCodeEditor();\n _updateCodeEditor();\n}\n\nfunction closeSkillEditor() {\n document.getElementById('skillEditorPage').style.display = 'none';\n document.getElementById('skillsMain').style.display = '';\n _skillEditing = null;\n}\nconst closeSkillModal = closeSkillEditor;\n\nasync function saveSkillEdit() {\n const source = document.getElementById('skillEditSource').value;\n try {\n if (_skillEditing) {\n await api('PATCH', `/api/skills/${encodeURIComponent(_skillEditing)}`, { source });\n toast(t('skills.updated') || 'Skill updated');\n } else {\n const name = document.getElementById('skillEditName').value.trim();\n if (!name) { toast('Enter a skill name', true); return; }\n await api('POST', '/api/skills/', { name, source });\n toast(t('skills.created') || 'Skill created');\n }\n closeSkillEditor();\n loadSkills();\n } catch (e) {\n toast('Error: ' + e.message, true);\n }\n}\n\nasync function toggleSkill(name) {\n try {\n await api('POST', `/api/skills/${encodeURIComponent(name)}/toggle`);\n loadSkills();\n } catch (e) {\n toast('Error: ' + e.message, true);\n }\n}\n\nasync function deleteSkill(name) {\n if (!confirm(`Delete skill \"${name}\"?`)) return;\n try {\n await api('DELETE', `/api/skills/${encodeURIComponent(name)}`);\n toast(t('skills.deleted') || 'Skill deleted');\n loadSkills();\n } catch (e) {\n toast('Error: ' + e.message, true);\n }\n}\n\nasync function reloadSkills() {\n try {\n const res = await api('POST', '/api/skills/reload');\n toast(`${res.count} skills loaded`);\n loadSkills();\n } catch (e) {\n toast('Error: ' + e.message, true);\n }\n}\n\nasync function testSkill() {\n const name = _skillEditing;\n if (!name) { toast('Save the skill first before testing', true); return; }\n const outputEl = document.getElementById('skillTestOutput');\n let inputData = {};\n try {\n const raw = document.getElementById('skillTestInput').value.trim();\n if (raw) inputData = JSON.parse(raw);\n } catch {\n toast('Invalid JSON in test input', true);\n return;\n }\n outputEl.style.display = 'block';\n outputEl.textContent = 'Running...';\n try {\n const result = await api('POST', `/api/skills/${encodeURIComponent(name)}/test`, { input_data: inputData });\n outputEl.textContent = JSON.stringify(result, null, 2);\n outputEl.style.color = result.success ? 'var(--success)' : 'var(--danger)';\n } catch (e) {\n outputEl.textContent = 'Error: ' + e.message;\n outputEl.style.color = 'var(--danger)';\n }\n}\n\n// ══════════════════════════════════════════════════\n// HOW TO CONNECT — SLIDER\n// ══════════════════════════════════════════════════\n\nlet _htcIndex = 0;\nconst _htcTotal = 4;\n\nfunction _htcUpdate() {\n document.querySelectorAll('.htc-slide').forEach((s, i) => s.classList.toggle('active', i === _htcIndex));\n document.querySelectorAll('.htc-dot').forEach((d, i) => d.classList.toggle('active', i === _htcIndex));\n document.querySelector('.htc-prev').disabled = _htcIndex === 0;\n const nextBtn = document.querySelector('.htc-next');\n nextBtn.textContent = _htcIndex === _htcTotal - 1 ? '✓' : t('htc.next');\n nextBtn.disabled = _htcIndex === _htcTotal - 1;\n}\n\nfunction htcNext() { if (_htcIndex < _htcTotal - 1) { _htcIndex++; _htcUpdate(); } }\nfunction htcPrev() { if (_htcIndex > 0) { _htcIndex--; _htcUpdate(); } }\nfunction htcGo(i) { _htcIndex = i; _htcUpdate(); }\n\n// ══════════════════════════════════════════════════\n// STATISTICS SUB-TABS DATA\n// ══════════════════════════════════════════════════\n\nlet _cachedInfo = null;\n\nasync function loadStatsMemory() {\n try {\n const memUsers = await api('GET', '/api/memory/users');\n const users = memUsers.users || [];\n document.getElementById('statsMemUsers').textContent = users.length;\n\n // Load per-user stats\n let totalMem = 0;\n const userStats = [];\n const catAgg = {};\n for (const u of users) {\n try {\n const s = await api('GET', `/api/memory/stats/${encodeURIComponent(u)}`);\n userStats.push({ name: u, total: s.total, categories: s.by_category || {} });\n totalMem += s.total;\n for (const [cat, count] of Object.entries(s.by_category || {})) {\n catAgg[cat] = (catAgg[cat] || 0) + count;\n }\n } catch { /* skip */ }\n }\n\n document.getElementById('statsMemTotal').textContent = totalMem;\n // Auto-extract status\n try {\n const cfg = await api('GET', '/api/settings/');\n document.getElementById('statsMemAutoExtract').textContent = cfg.memory.auto_extract ? t('status.active') : t('status.disabled');\n } catch {\n document.getElementById('statsMemAutoExtract').textContent = '—';\n }\n\n // User table\n document.getElementById('statsMemoryUserTable').innerHTML = userStats.length\n ? userStats.map(u => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(u.name)}</span>\n <span class=\"stats-detail-num\">${u.total} memories</span>\n </div>`).join('')\n : `<p class=\"card-muted\">${t('stats.noData')}</p>`;\n\n // Category table\n const catEntries = Object.entries(catAgg).sort((a, b) => b[1] - a[1]);\n document.getElementById('statsMemoryCatTable').innerHTML = catEntries.length\n ? catEntries.map(([cat, count]) => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${t('cat.' + cat) || cat}</span>\n <span class=\"stats-detail-num\">${count}</span>\n </div>`).join('')\n : `<p class=\"card-muted\">${t('stats.noData')}</p>`;\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function loadStatsSkills() {\n try {\n const list = await api('GET', '/api/skills/');\n const total = list.length;\n const enabled = list.filter(s => !s.disabled).length;\n const builtin = list.filter(s => !s.generated).length;\n const generated = list.filter(s => s.generated).length;\n const totalUsage = list.reduce((sum, s) => sum + (s.usage_count || 0), 0);\n\n document.getElementById('statsSkillsTotal').textContent = total;\n document.getElementById('statsSkillsEnabled').textContent = enabled;\n document.getElementById('statsSkillsBuiltin').textContent = builtin;\n document.getElementById('statsSkillsGenerated').textContent = generated;\n document.getElementById('statsSkillsTotalUsage').textContent = totalUsage;\n\n document.getElementById('statsSkillsTable').innerHTML = list.length\n ? list.map(s => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(s.name)} ${s.generated ? '<span class=\"stats-detail-badge\">user</span>' : '<span class=\"stats-detail-badge\">built-in</span>'}</span>\n <span class=\"stats-detail-meta\">${s.disabled ? '⏸ disabled' : '✅ active'} · ${t('stats.used')} ${s.usage_count || 0}x</span>\n </div>`).join('')\n : `<p class=\"card-muted\">${t('stats.noData')}</p>`;\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nasync function loadStatsServer() {\n try {\n const info = await api('GET', '/api/settings/info');\n _cachedInfo = info;\n\n document.getElementById('statsServerUptime').textContent = formatUptime(info.uptime_seconds);\n document.getElementById('statsServerVersion').textContent = info.version;\n document.getElementById('statsServerEndpoints').textContent = info.endpoints.length;\n document.getElementById('statsServerProviders').textContent = (info.providers || []).length;\n\n // Server details\n const details = [\n { label: t('stats.localIp'), value: info.local_ip || '—' },\n { label: t('stats.port'), value: info.port || '8899' },\n { label: t('stats.activeProvider'), value: info.active_provider || '—' },\n { label: t('info.uptime'), value: formatUptime(info.uptime_seconds) },\n { label: t('stats.totalUsers'), value: info.stats.total_users },\n { label: t('info.totalMemories'), value: info.stats.total_memories },\n { label: t('info.messages'), value: info.stats.total_conversations },\n { label: t('info.actions24h'), value: info.stats.actions_last_24h },\n ];\n document.getElementById('statsServerDetails').innerHTML = details.map(d => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(d.label)}</span>\n <span class=\"stats-detail-num\">${escapeHtml(String(d.value))}</span>\n </div>`).join('');\n\n // Service status\n const lm = info.services.lmstudio;\n const prov = info.services.provider || lm;\n const sx = info.services.searxng;\n const mem = info.services.memory;\n const services = [\n { name: prov.name || 'AI Provider', status: (prov.status || lm.status) === 'connected' ? '✅ ' + t('status.connected') : '❌ ' + t('status.unavailable'), detail: `${prov.url || lm.url} — ${prov.model || lm.model}` },\n { name: 'SearXNG', status: !sx.enabled ? '⏸ ' + t('status.disabled') : (sx.status === 'connected' ? '✅ ' + t('status.connected') : '❌ ' + t('status.unavailable')), detail: sx.url },\n { name: t('info.memoryAi'), status: mem.enabled ? '✅ ' + (mem.auto_extract ? t('status.activeAutoExtract') : t('status.active')) : '⏸ ' + t('status.disabled'), detail: t('status.memoriesStored', { count: info.stats.total_memories }) },\n ];\n document.getElementById('statsServiceDetails').innerHTML = services.map(s => `\n <div class=\"stats-detail-row\">\n <span class=\"stats-detail-name\">${escapeHtml(s.name)}</span>\n <span class=\"stats-detail-num\" style=\"white-space:nowrap\">${s.status}</span>\n <span class=\"stats-detail-meta\">${escapeHtml(s.detail)}</span>\n </div>`).join('');\n requestAnimationFrame(fitServerOverviewValues);\n } catch (e) {\n toast(t('toast.error', { msg: e.message }), true);\n }\n}\n\nwindow.addEventListener('resize', () => {\n requestAnimationFrame(fitServerOverviewValues);\n});\n"}}}}