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.
1 line
101 KiB
JSON
1 line
101 KiB
JSON
{"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' : ''}>‹</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' : ''}>›</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} ${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,'&').replace(/</g,'<').replace(/>/g,'>');\n return esc\n .replace(/(#.*?)$/gm, '<span class=\"syn-comment\">$1</span>')\n .replace(/("{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"}}}} |