{"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
${t('stats.noData')}
`;\n\n // Model detail table\n document.getElementById('statsModelTable').innerHTML = stats.by_model.length\n ? stats.by_model.map(m => `\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${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${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${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${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, '${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.
Error loading skills: ${escapeHtml(e.message)}
${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${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${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