| 'use strict'; |
|
|
| |
| let currentSessionId = genId(); |
| let currentProvider = 'groq'; |
| let currentModel = 'llama-3.3-70b-versatile'; |
| let currentModelLabel = 'Groq · Llama 3.3'; |
| let jurisdictionOverride = null; |
| let isStreaming = false; |
| let allModels = {}; |
| let settings = {}; |
|
|
| |
| document.addEventListener('DOMContentLoaded', async () => { |
| try { |
| const [modelsData, settingsData] = await Promise.all([ |
| api('/api/models'), |
| api('/api/settings') |
| ]); |
| allModels = modelsData; |
| settings = settingsData; |
|
|
| currentProvider = settings.provider || 'groq'; |
| currentModel = settings.model || firstModel(currentProvider); |
| jurisdictionOverride = settings.jurisdiction_override || null; |
|
|
| syncModelLabel(); |
| renderModelDropdown(); |
| await refreshSessions(); |
| } catch (e) { |
| console.error('Init failed:', e); |
| } |
| }); |
|
|
| |
| async function api(url, opts = {}) { |
| const resp = await fetch(url, opts); |
| if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); |
| return resp.json(); |
| } |
|
|
| function genId() { |
| return 'sess_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); |
| } |
|
|
| function firstModel(provider) { |
| const active = (settings.active_models || {})[provider] || []; |
| if (active.length) return active[0]; |
| return (allModels[provider] || [])[0]?.id || null; |
| } |
|
|
| function providerLabel(p) { |
| return { groq: 'Groq', openrouter: 'OpenRouter', ollama: 'Ollama' }[p] || p; |
| } |
|
|
| function shortModelName(id, name) { |
| if (name) return name.split(' ').slice(0, 3).join(' '); |
| return id.split('/').pop().split(':')[0]; |
| } |
|
|
| function getActiveModels() { |
| const active = settings.active_models || {}; |
| const result = {}; |
| for (const [p, catalog] of Object.entries(allModels)) { |
| const allowed = new Set(active[p] || catalog.map(m => m.id)); |
| result[p] = catalog.filter(m => allowed.has(m.id)); |
| } |
| return result; |
| } |
|
|
| function syncModelLabel() { |
| const catalog = (allModels[currentProvider] || []); |
| const m = catalog.find(m => m.id === currentModel); |
| currentModelLabel = |
| providerLabel(currentProvider) + ' · ' + shortModelName(currentModel, m?.name); |
| const el = document.getElementById('model-label'); |
| if (el) el.textContent = currentModelLabel; |
| } |
|
|
| |
| function renderModelDropdown() { |
| const dd = document.getElementById('model-dropdown'); |
| if (!dd) return; |
| dd.innerHTML = ''; |
|
|
| const activeModels = getActiveModels(); |
| let hasAny = false; |
|
|
| for (const [prov, models] of Object.entries(activeModels)) { |
| if (!models.length) continue; |
| hasAny = true; |
|
|
| const lbl = document.createElement('div'); |
| lbl.className = 'model-group-label'; |
| lbl.textContent = providerLabel(prov); |
| dd.appendChild(lbl); |
|
|
| models.forEach(m => { |
| const btn = document.createElement('button'); |
| btn.className = 'model-option' + (prov === currentProvider && m.id === currentModel ? ' selected' : ''); |
| btn.textContent = m.name; |
| btn.onclick = e => { e.stopPropagation(); selectModel(prov, m.id, m.name); }; |
| dd.appendChild(btn); |
| }); |
|
|
| const sep = document.createElement('hr'); |
| sep.className = 'model-separator'; |
| dd.appendChild(sep); |
| } |
|
|
| if (!hasAny) { |
| dd.innerHTML = '<div class="model-group-label" style="padding:0.75rem">No active models. Enable some in Settings → Models.</div>'; |
| } |
| } |
|
|
| function selectModel(provider, modelId, modelName) { |
| currentProvider = provider; |
| currentModel = modelId; |
| currentModelLabel = providerLabel(provider) + ' · ' + shortModelName(modelId, modelName); |
| document.getElementById('model-label').textContent = currentModelLabel; |
| renderModelDropdown(); |
| document.getElementById('model-dropdown').classList.add('hidden'); |
| } |
|
|
| function toggleModelDropdown(e) { |
| e.stopPropagation(); |
| document.getElementById('model-dropdown').classList.toggle('hidden'); |
| } |
|
|
| document.addEventListener('click', () => { |
| document.getElementById('model-dropdown')?.classList.add('hidden'); |
| }); |
|
|
| |
| async function refreshSessions() { |
| try { |
| const sessions = await api('/api/sessions'); |
| renderSessions(sessions); |
| } catch (e) { } |
| } |
|
|
| function renderSessions(sessions) { |
| const el = document.getElementById('session-list'); |
| if (!el) return; |
| el.innerHTML = ''; |
|
|
| if (!sessions || !sessions.length) { |
| el.innerHTML = '<div style="padding:.5rem .5rem;font-size:.75rem;color:var(--fg-muted)">No conversations yet</div>'; |
| return; |
| } |
|
|
| sessions.forEach(s => { |
| const item = document.createElement('div'); |
| item.className = 'session-item' + (s.session_id === currentSessionId ? ' active' : ''); |
| item.dataset.id = s.session_id; |
|
|
| const name = document.createElement('span'); |
| name.className = 'session-name'; |
| name.textContent = s.name || s.preview || s.session_id.slice(0, 10); |
| name.title = name.textContent; |
|
|
| const del = document.createElement('button'); |
| del.className = 'session-del'; |
| del.textContent = '×'; |
| del.title = 'Delete'; |
| del.onclick = async ex => { |
| ex.stopPropagation(); |
| await fetch(`/api/sessions/${s.session_id}`, { method: 'DELETE' }); |
| await refreshSessions(); |
| if (s.session_id === currentSessionId) newChat(); |
| }; |
|
|
| item.onclick = () => loadSession(s.session_id); |
| item.appendChild(name); |
| item.appendChild(del); |
| el.appendChild(item); |
| }); |
| } |
|
|
| async function loadSession(id) { |
| currentSessionId = id; |
| document.querySelectorAll('.session-item').forEach(el => |
| el.classList.toggle('active', el.dataset.id === id)); |
| clearMessages(); |
| hideEmpty(); |
|
|
| try { |
| const data = await api(`/api/sessions/${id}`); |
| const msgs = data.messages || []; |
| let lastUserQuestion = ''; |
| msgs.forEach(msg => { |
| if (msg.role === 'user') { |
| appendUserBubble(msg.content); |
| lastUserQuestion = msg.content; |
| } else { |
| const wrap = appendAIBubble(); |
| const contentEl = wrap.querySelector('.msg-ai-content'); |
| |
| contentEl.querySelector('.cursor')?.remove(); |
| |
| |
| let content = msg.content || ''; |
| let msgJur = 'Both'; |
| const jurMatch = content.match(/JURISDICTION:\s*(India|UAE|Both|General)/i); |
| if (jurMatch) { |
| msgJur = jurMatch[1]; |
| content = content.replace(/JURISDICTION:\s*(India|UAE|Both|General)/i, '').trim(); |
| } |
|
|
| if (typeof marked !== 'undefined') { |
| contentEl.innerHTML = marked.parse(content); |
| } else { |
| contentEl.textContent = content; |
| } |
|
|
| const outer = wrap.querySelector('.msg-ai'); |
| if (outer) outer.dataset.question = lastUserQuestion; |
| const sources = Array.isArray(msg.sources) ? msg.sources : []; |
| const confidence = sources.length ? 'GROUNDED' : 'SYNTHESIZED'; |
| renderMetaBar(outer, { sources, confidence, jurisdiction: msgJur }); |
| } |
| }); |
| scrollBottom(); |
| } catch (e) { |
| console.error('Load session error:', e); |
| } |
| } |
|
|
| function newChat() { |
| currentSessionId = genId(); |
| clearMessages(); |
| showEmpty(); |
| document.querySelectorAll('.session-item').forEach(el => el.classList.remove('active')); |
| } |
|
|
| |
| function handleKey(e) { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } |
| } |
|
|
| function useSuggestion(btn) { |
| document.getElementById('query-input').value = btn.textContent; |
| sendMessage(); |
| } |
|
|
| async function sendMessage() { |
| if (isStreaming) return; |
|
|
| const input = document.getElementById('query-input'); |
| const question = input.value.trim(); |
| if (!question) return; |
|
|
| input.value = ''; |
| autoResize(input); |
| hideEmpty(); |
| isStreaming = true; |
| setDisabled(true); |
|
|
| |
| appendUserBubble(question); |
|
|
| |
| const aiWrap = appendAIBubble(); |
| const outerEl = aiWrap.querySelector('.msg-ai'); |
| if (outerEl) outerEl.dataset.question = question; |
| const contentEl = aiWrap.querySelector('.msg-ai-content'); |
| const thinkEl = appendThinking(contentEl); |
| let thinkGone = false; |
|
|
| const removeThink = () => { |
| if (!thinkGone) { thinkEl?.remove(); thinkGone = true; } |
| }; |
|
|
| const updateStatus = (text) => { |
| const s = contentEl.querySelector('.status-text'); |
| if (s) s.textContent = text; |
| }; |
|
|
| |
| const watchdog = setTimeout(() => { |
| if (isStreaming) { |
| removeThink(); |
| if (!fullAnswer) { |
| contentEl.querySelector('.cursor')?.remove(); |
| contentEl.textContent = '⚠ Interaction timed out (100s). This could be a connection issue or high server load.'; |
| } |
| finishStreaming(); |
| } |
| }, 100000); |
|
|
| let meta = null; |
| let fullAnswer = ''; |
| let sessionName = question.slice(0, 60); |
|
|
| try { |
| const resp = await fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| question, |
| session_id: currentSessionId, |
| provider: currentProvider, |
| model: currentModel, |
| jurisdiction_override: jurisdictionOverride |
| }) |
| }); |
|
|
| if (!resp.ok) { |
| clearTimeout(watchdog); |
| removeThink(); |
| const err = await resp.text(); |
| contentEl.querySelector('.cursor')?.remove(); |
| contentEl.textContent = '⚠ Server error: ' + resp.status + ' - ' + err; |
| return finishStreaming(); |
| } |
|
|
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buf = ''; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buf += decoder.decode(value, { stream: true }); |
|
|
| |
| let lines = buf.split(/\r?\n/); |
| buf = lines.pop(); |
|
|
| let evType = null; |
| for (const line of lines) { |
| const trimmed = line.trim(); |
| if (!trimmed || trimmed.startsWith(':')) continue; |
|
|
| if (trimmed.startsWith('event:')) { |
| evType = trimmed.slice(6).trim(); |
| } else if (trimmed.startsWith('data:')) { |
| const dataStr = trimmed.slice(5).trim(); |
| let data; |
| try { data = JSON.parse(dataStr); } catch(e) { continue; } |
|
|
| switch (evType) { |
| case 'sources': |
| meta = data; |
| updateStatus('Synthesizing expert legal response...'); |
| break; |
|
|
| case 'token': |
| removeThink(); |
| if (data.content) { |
| appendToken(contentEl, data.content); |
| fullAnswer += data.content; |
| scrollBottom(); |
| } |
| break; |
|
|
| case 'done': |
| removeThink(); |
| sessionName = data.session_name || sessionName; |
| if (meta) Object.assign(meta, { session_name: sessionName }); |
| break; |
|
|
| case 'error': |
| removeThink(); |
| if (!fullAnswer) { |
| contentEl.querySelector('.cursor')?.remove(); |
| contentEl.textContent = '⚠ ' + (data.content || 'Unknown error'); |
| } |
| break; |
| } |
| evType = null; |
| } |
| } |
| } |
| } catch (e) { |
| removeThink(); |
| if (!fullAnswer) { |
| contentEl.querySelector('.cursor')?.remove(); |
| contentEl.textContent = '⚠ Network error: ' + e.message; |
| } |
| } |
|
|
| |
| clearTimeout(watchdog); |
| contentEl.querySelector('.cursor')?.remove(); |
| finalizeAI(aiWrap, meta); |
|
|
| await refreshSessions(); |
| finishStreaming(); |
| input.focus(); |
| } |
|
|
| function finishStreaming() { |
| isStreaming = false; |
| setDisabled(false); |
| } |
|
|
| |
| function appendUserBubble(text) { |
| const wrap = document.createElement('div'); |
| wrap.className = 'message-wrap'; |
| |
| const outer = document.createElement('div'); |
| outer.className = 'msg-user'; |
| |
| const bub = document.createElement('div'); |
| bub.className = 'msg-user-bubble'; |
| bub.textContent = text; |
| outer.appendChild(bub); |
| |
| const actions = document.createElement('div'); |
| actions.className = 'msg-user-actions'; |
| const editBtn = document.createElement('button'); |
| editBtn.className = 'icon-action-btn'; |
| editBtn.title = 'Edit'; |
| editBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`; |
| editBtn.onclick = () => { |
| const input = document.getElementById('query-input'); |
| input.value = text; |
| input.focus(); |
| autoResize(input); |
| }; |
| actions.appendChild(editBtn); |
| outer.appendChild(actions); |
| |
| wrap.appendChild(outer); |
| document.getElementById('messages').appendChild(wrap); |
| scrollBottom(); |
| return wrap; |
| } |
|
|
| function appendAIBubble() { |
| const wrap = document.createElement('div'); |
| wrap.className = 'message-wrap'; |
| const outer = document.createElement('div'); |
| outer.className = 'msg-ai'; |
| const content = document.createElement('div'); |
| content.className = 'msg-ai-content'; |
| const cursor = document.createElement('span'); |
| cursor.className = 'cursor'; |
| content.appendChild(cursor); |
| outer.appendChild(content); |
| wrap.appendChild(outer); |
| document.getElementById('messages').appendChild(wrap); |
| return wrap; |
| } |
|
|
| function appendThinking(parent) { |
| const el = document.createElement('div'); |
| el.className = 'thinking'; |
| el.innerHTML = ` |
| <div class="dots-row"> |
| <div class="thinking-dot"></div> |
| <div class="thinking-dot"></div> |
| <div class="thinking-dot"></div> |
| </div> |
| <div class="status-text">Searching legal archives...</div> |
| `; |
| parent.prepend(el); |
| return el; |
| } |
|
|
| function appendToken(contentEl, token) { |
| const cursor = contentEl.querySelector('.cursor'); |
| const node = document.createTextNode(token); |
| cursor ? contentEl.insertBefore(node, cursor) : contentEl.appendChild(node); |
| } |
|
|
| function finalizeAI(wrap, meta) { |
| const contentEl = wrap.querySelector('.msg-ai-content'); |
| let content = contentEl ? contentEl.textContent : ''; |
| let msgJur = meta?.jurisdiction || 'Both'; |
| |
| |
| const jurMatch = content.match(/JURISDICTION:\s*(India|UAE|Both|General)/i); |
| if (jurMatch) { |
| msgJur = jurMatch[1]; |
| content = content.replace(/JURISDICTION:\s*(India|UAE|Both|General)/i, '').trim(); |
| } |
| |
| if (contentEl) { |
| if (typeof marked !== 'undefined') { |
| contentEl.innerHTML = marked.parse(content); |
| } else { |
| contentEl.textContent = content; |
| } |
| } |
| |
| if (!meta) return; |
| const outer = wrap.querySelector('.msg-ai'); |
| if (outer) { |
| meta.jurisdiction = msgJur; |
| renderMetaBar(outer, meta); |
| } |
| } |
|
|
| function renderMetaBar(outer, meta) { |
| const confidence = meta.confidence || 'GROUNDED'; |
| const jurisdiction = meta.jurisdiction || 'Both'; |
| const sources = meta.sources || []; |
| const isGeneral = (confidence === 'GENERAL' || confidence === 'SYNTHESIZED'); |
|
|
| const bar = document.createElement('div'); |
| bar.className = 'msg-meta'; |
|
|
| |
| const confMap = { |
| GROUNDED: ['badge-grounded', '✓ Verified Sources'], |
| PARTIAL: ['badge-partial', '✦ Assisted Research'], |
| SYNTHESIZED: ['badge-general', '🗎 Independent Analysis'], |
| GENERAL: ['badge-general', '🗎 Independent Analysis'] |
| }; |
| const [cls, lbl] = confMap[confidence] || confMap.PARTIAL; |
| const confBadge = document.createElement('span'); |
| confBadge.className = `badge ${cls}`; |
| confBadge.textContent = lbl; |
| bar.appendChild(confBadge); |
|
|
| |
| const jurIcon = { India: '🇮🇳', UAE: '🇦🇪', Both: '🌐', General: '🌐' }[jurisdiction] || '🌐'; |
| const jurBadge = document.createElement('span'); |
| jurBadge.className = 'badge-jur'; |
| jurBadge.textContent = `${jurIcon} ${jurisdiction}`; |
| bar.appendChild(jurBadge); |
|
|
| |
| const actionGroup = document.createElement('div'); |
| actionGroup.className = 'msg-ai-actions'; |
|
|
| const copyBtn = document.createElement('button'); |
| copyBtn.className = 'icon-action-btn'; |
| copyBtn.title = 'Copy'; |
| copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; |
| copyBtn.onclick = () => { |
| const textEl = outer.querySelector('.msg-ai-content'); |
| const textToCopy = textEl ? textEl.innerText : ''; |
| navigator.clipboard.writeText(textToCopy).then(() => { |
| copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`; |
| copyBtn.style.color = 'var(--green)'; |
| setTimeout(() => { |
| copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; |
| copyBtn.style.color = ''; |
| }, 2000); |
| }); |
| }; |
| actionGroup.appendChild(copyBtn); |
|
|
| const retryBtn = document.createElement('button'); |
| retryBtn.className = 'icon-action-btn'; |
| retryBtn.title = 'Retry'; |
| retryBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.96"/></svg>`; |
| retryBtn.onclick = () => { |
| const question = outer.dataset.question || ''; |
| if (question) { |
| document.getElementById('query-input').value = question; |
| sendMessage(); |
| } |
| }; |
| actionGroup.appendChild(retryBtn); |
|
|
| bar.appendChild(actionGroup); |
|
|
| outer.appendChild(bar); |
|
|
| |
| if (isGeneral) { |
| const btn = document.createElement('button'); |
| btn.className = 'disclaimer-btn'; |
| btn.title = 'Disclaimer'; |
| btn.textContent = 'ⓘ'; |
| bar.appendChild(btn); |
|
|
| const box = document.createElement('div'); |
| box.className = 'disclaimer-box'; |
| box.textContent = 'This response is synthesized from the AI model\'s general knowledge, rather than being retrieved from LexRAG\'s verified document base. It may not reflect the most current statutory provisions. Always consult a qualified legal professional before acting.'; |
| btn.onclick = () => box.classList.toggle('visible'); |
| outer.appendChild(box); |
| } |
|
|
| |
| if (sources.length) { |
| const toggle = document.createElement('button'); |
| toggle.className = 'sources-toggle'; |
| toggle.textContent = `${sources.length} source${sources.length > 1 ? 's' : ''} ↓`; |
| bar.appendChild(toggle); |
|
|
| const panel = document.createElement('div'); |
| panel.className = 'sources-panel'; |
| sources.forEach(s => { |
| const item = document.createElement('div'); |
| item.className = 'source-item'; |
| item.innerHTML = ` |
| <span>${s.title || s.source || 'Source'}</span> |
| <span class="badge-jur" style="font-size:.55rem">${s.jurisdiction || ''}</span> |
| <span class="source-score">${s.score != null ? s.score.toFixed(2) : ''}</span> |
| ${s.url ? `<a href="${s.url}" target="_blank" class="source-link" onclick="event.stopPropagation()">↗</a>` : ''} |
| `; |
| panel.appendChild(item); |
| }); |
|
|
| toggle.onclick = () => { |
| const open = panel.classList.toggle('visible'); |
| toggle.textContent = `${sources.length} source${sources.length > 1 ? 's' : ''} ${open ? '↑' : '↓'}`; |
| }; |
| outer.appendChild(panel); |
| } |
| } |
|
|
| |
| function openSettings() { |
| document.getElementById('settings-overlay').classList.remove('hidden'); |
| const panel = document.getElementById('settings-panel'); |
| panel.classList.remove('hidden'); |
| requestAnimationFrame(() => panel.classList.add('visible')); |
| populateSettings(); |
| } |
|
|
| function closeSettings() { |
| const panel = document.getElementById('settings-panel'); |
| panel.classList.remove('visible'); |
| setTimeout(() => { |
| panel.classList.add('hidden'); |
| document.getElementById('settings-overlay').classList.add('hidden'); |
| }, 300); |
| } |
|
|
| function switchSettingsTab(tab, btn) { |
| document.querySelectorAll('.stab-content').forEach(el => el.classList.add('hidden')); |
| document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active')); |
| document.getElementById(`stab-${tab}`).classList.remove('hidden'); |
| btn.classList.add('active'); |
| } |
|
|
| |
| function populateSettings() { |
| |
| const pills = document.getElementById('s-provider-pills'); |
| if (pills) { |
| pills.innerHTML = ''; |
| ['groq', 'openrouter', 'ollama'].forEach(p => { |
| const btn = document.createElement('button'); |
| btn.className = 'pill-btn' + (p === (settings.provider || currentProvider) ? ' active' : ''); |
| btn.textContent = providerLabel(p); |
| btn.onclick = () => { |
| document.querySelectorAll('#s-provider-pills .pill-btn').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| populateSettingsModelSelect(p); |
| }; |
| pills.appendChild(btn); |
| }); |
| } |
| populateSettingsModelSelect(settings.provider || currentProvider); |
|
|
| |
| document.querySelectorAll('#s-jur-pills button[data-jur]').forEach(btn => { |
| const v = btn.dataset.jur === 'null' ? null : btn.dataset.jur; |
| btn.classList.toggle('active', v === jurisdictionOverride); |
| }); |
|
|
| |
| populateModelsTab(); |
|
|
| |
| populateCustomTab(); |
| } |
|
|
| function populateSettingsModelSelect(provider) { |
| const sel = document.getElementById('s-model-select'); |
| if (!sel) return; |
| sel.innerHTML = ''; |
| (allModels[provider] || []).forEach(m => { |
| const opt = document.createElement('option'); |
| opt.value = m.id; |
| opt.textContent = m.name; |
| opt.selected = m.id === (settings.model || currentModel); |
| sel.appendChild(opt); |
| }); |
| } |
|
|
| function setJurSetting(btn) { |
| document.querySelectorAll('#s-jur-pills button[data-jur]').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| jurisdictionOverride = btn.dataset.jur === 'null' ? null : btn.dataset.jur; |
| } |
|
|
| |
| function populateModelsTab() { |
| const list = document.getElementById('s-models-list'); |
| if (!list) return; |
| list.innerHTML = ''; |
|
|
| const activeModels = settings.active_models || {}; |
|
|
| for (const [prov, models] of Object.entries(allModels)) { |
| if (!models.length) continue; |
| const group = document.createElement('div'); |
| group.className = 'model-provider-group'; |
|
|
| const title = document.createElement('div'); |
| title.className = 'model-provider-title'; |
| title.textContent = providerLabel(prov); |
| group.appendChild(title); |
|
|
| const activeSet = new Set(activeModels[prov] || models.map(m => m.id)); |
|
|
| models.forEach(m => { |
| const row = document.createElement('div'); |
| row.className = 'model-toggle-row'; |
|
|
| const nameEl = document.createElement('span'); |
| nameEl.className = 'model-toggle-name'; |
| nameEl.textContent = m.name; |
|
|
| const idEl = document.createElement('span'); |
| idEl.className = 'model-toggle-id'; |
| idEl.textContent = m.id.split('/').pop().split(':')[0]; |
|
|
| const sw = document.createElement('label'); |
| sw.className = 'toggle-switch'; |
| sw.title = activeSet.has(m.id) ? 'Active — click to deactivate' : 'Inactive — click to activate'; |
|
|
| const inp = document.createElement('input'); |
| inp.type = 'checkbox'; |
| inp.checked = activeSet.has(m.id); |
| inp.dataset.provider = prov; |
| inp.dataset.modelId = m.id; |
|
|
| const slider = document.createElement('span'); |
| slider.className = 'toggle-slider'; |
|
|
| sw.appendChild(inp); |
| sw.appendChild(slider); |
|
|
| const delBtn = document.createElement('button'); |
| delBtn.className = 'model-del-btn'; |
| delBtn.textContent = '×'; |
| delBtn.title = 'Remove model'; |
| delBtn.onclick = () => removeModelFromCatalog(prov, m.id); |
|
|
| row.appendChild(nameEl); |
| row.appendChild(idEl); |
| row.appendChild(sw); |
| row.appendChild(delBtn); |
| group.appendChild(row); |
| }); |
|
|
| list.appendChild(group); |
| } |
| } |
|
|
| function removeModelFromCatalog(provider, modelId) { |
| |
| if (allModels[provider]) { |
| allModels[provider] = allModels[provider].filter(m => m.id !== modelId); |
| } |
| |
| const custom = settings.custom_models || {}; |
| if (custom[provider]) { |
| custom[provider] = custom[provider].filter(m => m.id !== modelId); |
| } |
| |
| const active = settings.active_models || {}; |
| if (active[provider]) { |
| active[provider] = active[provider].filter(id => id !== modelId); |
| } |
| settings.custom_models = custom; |
| settings.active_models = active; |
|
|
| |
| if (currentModel === modelId) { |
| const remaining = allModels[currentProvider] || []; |
| currentModel = remaining[0]?.id || ''; |
| syncModelLabel(); |
| renderModelDropdown(); |
| } |
|
|
| saveSettings(true); |
| populateSettings(); |
| } |
|
|
| |
| function populateCustomTab() { |
| const list = document.getElementById('s-custom-list'); |
| if (!list) return; |
| list.innerHTML = ''; |
|
|
| const custom = settings.custom_models || {}; |
| let any = false; |
| for (const [prov, models] of Object.entries(custom)) { |
| models.forEach(m => { |
| any = true; |
| const item = document.createElement('div'); |
| item.className = 'custom-model-item'; |
| item.innerHTML = `<span>${m.name} <span style="color:var(--fg-muted);font-size:.75rem">(${providerLabel(prov)})</span></span>`; |
| const del = document.createElement('button'); |
| del.className = 'custom-del-btn'; |
| del.textContent = '×'; |
| del.onclick = () => removeCustomModel(prov, m.id); |
| item.appendChild(del); |
| list.appendChild(item); |
| }); |
| } |
| if (!any) { |
| list.innerHTML = '<div style="padding:1rem 1.5rem;font-size:.8rem;color:var(--fg-muted)">No custom models added yet.</div>'; |
| } |
| } |
|
|
| async function addCustomModel() { |
| const provider = document.getElementById('c-provider').value; |
| const id = document.getElementById('c-model-id').value.trim(); |
| const name = document.getElementById('c-model-name').value.trim(); |
| if (!id || !name) { alert('Please enter both Model ID and display name.'); return; } |
|
|
| const custom = settings.custom_models || {}; |
| if (!custom[provider]) custom[provider] = []; |
|
|
| |
| if (custom[provider].find(m => m.id === id)) { alert('Model already added.'); return; } |
| custom[provider].push({ id, name }); |
|
|
| |
| if (!allModels[provider]) allModels[provider] = []; |
| if (!allModels[provider].find(m => m.id === id)) allModels[provider].push({ id, name }); |
|
|
| |
| const active = settings.active_models || {}; |
| if (!active[provider]) active[provider] = []; |
| if (!active[provider].includes(id)) active[provider].push(id); |
|
|
| settings.custom_models = custom; |
| settings.active_models = active; |
|
|
| document.getElementById('c-model-id').value = ''; |
| document.getElementById('c-model-name').value = ''; |
|
|
| |
| await saveSettings(true); |
| populateSettings(); |
| } |
|
|
| async function removeCustomModel(provider, modelId) { |
| const custom = settings.custom_models || {}; |
| if (custom[provider]) { |
| custom[provider] = custom[provider].filter(m => m.id !== modelId); |
| } |
| const active = settings.active_models || {}; |
| if (active[provider]) { |
| active[provider] = active[provider].filter(id => id !== modelId); |
| } |
| if (allModels[provider]) { |
| allModels[provider] = allModels[provider].filter(m => m.id !== modelId); |
| } |
| settings.custom_models = custom; |
| settings.active_models = active; |
| if (allModels[provider]) { |
| allModels[provider] = allModels[provider].filter(m => m.id !== modelId); |
| } |
| |
| await saveSettings(true); |
| populateSettings(); |
| } |
|
|
| async function saveSettings(skipClose = false) { |
| |
| const activePill = document.querySelector('#s-provider-pills .pill-btn.active'); |
| const provLabels = ['groq', 'openrouter', 'ollama']; |
| let selProvider = settings.provider || currentProvider; |
| if (activePill) { |
| const idx = [...document.querySelectorAll('#s-provider-pills .pill-btn')].indexOf(activePill); |
| if (idx >= 0) selProvider = provLabels[idx]; |
| } |
|
|
| const selModel = document.getElementById('s-model-select')?.value || currentModel; |
|
|
| |
| const activeModels = {}; |
| document.querySelectorAll('.toggle-switch input[type="checkbox"]').forEach(inp => { |
| const prov = inp.dataset.provider; |
| const id = inp.dataset.modelId; |
| if (!activeModels[prov]) activeModels[prov] = []; |
| if (inp.checked) activeModels[prov].push(id); |
| }); |
|
|
| const toSave = { |
| provider: selProvider, |
| model: selModel, |
| jurisdiction_override: jurisdictionOverride, |
| active_models: activeModels, |
| custom_models: settings.custom_models || {} |
| }; |
|
|
| try { |
| const updated = await api('/api/settings', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(toSave) |
| }); |
| settings = updated; |
| currentProvider = selProvider; |
| currentModel = selModel; |
| syncModelLabel(); |
| renderModelDropdown(); |
| if (!skipClose) closeSettings(); |
| } catch (e) { |
| alert('Failed to save settings: ' + e.message); |
| } |
| } |
|
|
| |
| function clearMessages() { document.getElementById('messages').innerHTML = ''; } |
| function showEmpty() { document.getElementById('empty-state').classList.remove('hidden'); } |
| function hideEmpty() { document.getElementById('empty-state').classList.add('hidden'); } |
| function scrollBottom() { |
| const el = document.getElementById('messages'); |
| el.scrollTop = el.scrollHeight; |
| } |
| function setDisabled(v) { |
| document.getElementById('send-btn').disabled = v; |
| document.getElementById('query-input').disabled = v; |
| } |
| function autoResize(el) { |
| el.style.height = 'auto'; |
| el.style.height = Math.min(el.scrollHeight, 180) + 'px'; |
| } |
|
|