{"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
\n ${escapeHtml(ep.method)}\n ${escapeHtml(ep.path)}\n ${escapeHtml(ep.description)}\n
\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 `${escapeHtml(d.label)} (${d.value})`\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
\n ${escapeHtml(p.provider_name || p.provider_id)} ${escapeHtml(p.provider_type)}\n ${p.requests} req\n ${_formatNumber(p.tokens)} tok\n ${_formatMs(p.avg_response_ms)} avg\n
`).join('')\n : `

${t('stats.noData')}

`;\n\n // Model detail table\n document.getElementById('statsModelTable').innerHTML = stats.by_model.length\n ? stats.by_model.map(m => `\n
\n ${escapeHtml(m.model)} ${escapeHtml(m.provider_type)}\n ${m.requests} req\n ${_formatNumber(m.tokens)} tok\n ${_formatMs(m.avg_response_ms)} avg\n
`).join('')\n : `

${t('stats.noData')}

`;\n\n // User detail table\n document.getElementById('statsUserTable').innerHTML = stats.by_user.length\n ? stats.by_user.map(u => `\n
\n ${escapeHtml(u.user_id)}\n ${u.requests} req\n ${_formatNumber(u.tokens)} tok\n
`).join('')\n : `

${t('stats.noData')}

`;\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 = `

${t('settings.noProviders')}

`;\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 ? `${t('settings.secondaryShort')}: ${escapeHtml(secProv.name)}` : '';\n return `\n
\n
\n
\n ${isActive ? '✅ ' : ''}${escapeHtml(p.name)}\n ${escapeHtml(typeLabel)}\n ${secLabel}\n
\n
${escapeHtml(p.base_url)} — model: ${escapeHtml(p.model || 'default')}
\n
\n
\n ${!isActive ? `` : ''}\n \n \n
\n
`;\n }).join('');\n}\n\nfunction _populateSecondarySelect(excludeId) {\n const sel = document.getElementById('provSecondary');\n sel.innerHTML = ``;\n for (const p of _allSecondaryProviders) {\n sel.innerHTML += ``;\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 => ``;\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 = `
${stats.total}
${t('modal.total')}
`;\n for (const [cat, count] of Object.entries(stats.by_category || {})) {\n html += `
${count}
${catLabel(cat)}
`;\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 = `

${t('status.noMemory')}

`;\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
\n \n
\n ${catLabel(m.category)}\n ${stars}\n
${escapeHtml(m.content)}
\n
\n ${date}\n ${accessed}\n ${escapeHtml(m.keywords || '-')}\n ${m.source}\n
\n
\n
`;\n }).join('');\n // Pagination controls\n if (totalPages > 1) {\n let pag = ``;\n for (let i = 1; i <= totalPages; i++) {\n pag += ``;\n }\n pag += ``;\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 = ``;\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 = `

${t('conv.noConversations')}

`;\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
\n \n
\n
${dateStr}   ${timeStr} — ${lastTimeStr}
\n
\n ${s.message_count} ${t('conv.messages')}\n ${durationStr}\n
\n
\n
›
\n
`;\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 = `

${t('conv.loadingMessages')}

`;\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 = `

${t('conv.noMessages')}

`;\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, '
');\n return `\n
\n
\n ${roleLabel}\n ${time}\n
\n
${content}
\n
`;\n }).join('');\n } catch (e) {\n body.innerHTML = `

${t('toast.error', { msg: escapeHtml(e.message) })}

`;\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 = `${t('logs.noLogs')}`;\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 `${escapeHtml(e.ts)} ${lvl.padEnd(8)} [${name}] ${msg}`;\n }).join('\\n');\n // Auto-scroll to bottom\n pre.scrollTop = pre.scrollHeight;\n } catch (e) {\n pre.innerHTML = `${t('logs.loadError', { msg: e.message })}`;\n }\n}\n\nfunction clearLogsView() {\n document.getElementById('logsOutput').innerHTML = `${t('logs.cleared')}`;\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,'>');\n return esc\n .replace(/(#.*?)$/gm, '$1')\n .replace(/("{3}|'{3})([\\s\\S]*?)\\1/g, '$1$2$1')\n .replace(/([\"'])(?:(?!\\1|\\\\).|\\\\.)*?\\1/g, '$&')\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, '$1')\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, '$1')\n .replace(/\\b(self)\\b/g, '$1')\n .replace(/@[\\w.]+/g, '$&')\n .replace(/\\b(\\d+\\.?\\d*)\\b/g, '$1')\n .replace(/\\b([a-zA-Z_]\\w*)\\s*(?=\\()/g, '$1');\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 += `${i}`;\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 = '

No skills installed. Click \"+ New Skill\" to create one.

';\n applyTranslations();\n return;\n }\n el.innerHTML = list.map(s => `\n
\n
\n
\n ${escapeHtml(s.name)}\n ${s.generated ? 'generated' : 'built-in'}\n ${s.disabled ? 'disabled' : ''}\n
\n
${escapeHtml(s.description)}
\n
\n
\n \n \n ${s.generated ? `` : ''}\n
\n
\n `).join('');\n applyTranslations();\n } catch (e) {\n el.innerHTML = `

Error loading skills: ${escapeHtml(e.message)}

`;\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
\n ${escapeHtml(u.name)}\n ${u.total} memories\n
`).join('')\n : `

${t('stats.noData')}

`;\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
\n ${t('cat.' + cat) || cat}\n ${count}\n
`).join('')\n : `

${t('stats.noData')}

`;\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
\n ${escapeHtml(s.name)} ${s.generated ? 'user' : 'built-in'}\n ${s.disabled ? '⏸ disabled' : '✅ active'} · ${t('stats.used')} ${s.usage_count || 0}x\n
`).join('')\n : `

${t('stats.noData')}

`;\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
\n ${escapeHtml(d.label)}\n ${escapeHtml(String(d.value))}\n
`).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
\n ${escapeHtml(s.name)}\n ${s.status}\n ${escapeHtml(s.detail)}\n
`).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"}}}}