// settings-provider.js — 설정: 인증/로그아웃, 모델·프로바이더·크리덴셜, 오케스트레이션 프리셋/설정. // extracted from app.js (v4.3.19-3 split). const LAST_USER_KEY = 'homeclaw_last_user'; function clearLocalUserState(reason) { try { localStorage.removeItem(chatSessionsKey()); localStorage.removeItem(agentSessionKey()); } catch {} try { chatSessions = []; activeChatSessionId = null; } catch {} setAgentSessionId(generateSessionId()); // Rebuild a fresh "New chat" + re-render so the UI does not briefly // display the previous user's session list before the wipe takes effect. try { if (typeof loadChatSessions === 'function') loadChatSessions(); } catch {} try { if (typeof renderSessionsList === 'function') renderSessionsList(); } catch {} try { if (typeof renderMessages === 'function') renderMessages(); } catch {} console.log(`[auth] cleared local session state (${reason})`); } function applyUserBranding() { userLogoUrl = `/api/branding/logo?_=${Date.now()}`; brandName = (currentUser && currentUser.brandName) || 'HomeClaw'; const headerLogo = document.querySelector('header .logo img'); if (headerLogo) headerLogo.src = userLogoUrl; document.querySelectorAll('.chat-welcome-icon img').forEach((img) => { img.src = userLogoUrl; }); const headerName = document.getElementById('header-brand-name'); if (headerName) headerName.textContent = brandName; const welcomeName = document.getElementById('welcome-brand-name'); if (welcomeName) welcomeName.textContent = brandName; if (typeof renderChatMessages === 'function') renderChatMessages(); } async function checkAuth() { try { const res = await fetch('/api/auth/status', { headers: authHeaders() }); const data = await res.json(); if (data.enabled && !data.authenticated && !data.hasPassword) { window.location.href='/html/login.html'; return; } if (data.enabled && !data.authenticated) { window.location.href='/html/login.html'; return; } if (data.authenticated && data.username) { currentUser = { username: data.username, role: data.role, brandName: data.brandName }; applyUserBranding(); // Reload chat sessions under the correct user-scoped key now that we know who is logged in. // This also migrates any data from the old non-namespaced key on first load after server restart. if (typeof loadChatSessions === 'function') loadChatSessions(); _fetchAppCtxMax(); // Multi-user safety: if a different user was logged in on this browser // before, wipe the cached session list & active sessionId so the // incoming user does not reuse the previous user's chat IDs. const lastUser = (localStorage.getItem(LAST_USER_KEY) || '').trim(); if (lastUser && lastUser !== data.username) { clearLocalUserState(`user changed: ${lastUser} -> ${data.username}`); } try { localStorage.setItem(LAST_USER_KEY, data.username); } catch {} // Show user pill with username const pill = document.getElementById('user-info-pill'); const nameEl = document.getElementById('user-info-name'); if (pill) pill.style.display = 'flex'; if (nameEl) nameEl.textContent = data.username; const mobileLogout = document.getElementById('mob-tab-logout'); if (mobileLogout) mobileLogout.style.display = 'flex'; } } catch (e) { console.error('Auth check failed:', e); } } async function logout() { try { await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() }); } catch (e) { /* ignore network errors */ } // Wipe cached chat list / sessionId so a different user logging in next // does not inherit any of this user's session IDs. clearLocalUserState('logout'); try { localStorage.removeItem(LAST_USER_KEY); } catch {} setAuthToken(''); window.location.href='/html/login.html'; } async function loadSearchSettingsSummary() { try { const s = await api('/api/settings/search'); const el = document.getElementById('r-failed'); if (el) el.textContent = s.preferred_provider || 'ddg'; quickSearchRigor = s.search_rigor || 'verified'; updateQuickModeUI(); } catch {} } function updateQuickModeUI() { const lbl = document.getElementById('quick-mode-label'); if (lbl) lbl.textContent = (quickSearchRigor || 'verified').replace(/^./, c => c.toUpperCase()); ['fast', 'verified', 'strict'].forEach(v => { const el = document.getElementById(`rigor-${v}`); if (el) el.classList.toggle('active', quickSearchRigor === v); }); ['standard', 'extended'].forEach(v => { const el = document.getElementById(`think-${v}`); if (el) el.classList.toggle('active', quickThinkingEffort === v); }); } function toggleQuickModePopover() { const pop = document.getElementById('quick-mode-popover'); if (!pop) return; const nextOpen = !pop.classList.contains('open'); pop.classList.toggle('open', nextOpen); if (nextOpen) { pop.classList.remove('clamp-left'); requestAnimationFrame(() => { const rect = pop.getBoundingClientRect(); if (rect.left < 8) pop.classList.add('clamp-left'); const after = pop.getBoundingClientRect(); if (after.top < 8) { pop.style.bottom = 'auto'; pop.style.top = '34px'; } else { pop.style.top = ''; pop.style.bottom = '34px'; } }); } updateQuickModeUI(); } function toggleOllamaModelRow(provider) { const row = document.getElementById('search-ollama-model-row'); if (row) row.style.display = provider === 'ollama' ? 'block' : 'none'; } function toggleSearxngUrlRow(provider) { const row = document.getElementById('search-searxng-url-row'); if (row) row.style.display = provider === 'searxng' ? 'block' : 'none'; } async function setQuickSearchRigor(level) { quickSearchRigor = level; updateQuickModeUI(); try { const s = await api('/api/settings/search'); const payload = { preferred_provider: s.preferred_provider || 'ddg', search_rigor: level, tavily_api_key: s.tavily_api_key || '', google_api_key: s.google_api_key || '', google_cx: s.google_cx || '', brave_api_key: s.brave_api_key || '', }; await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) }); addProcessEntry('info', `Search rigor set to ${level}.`); await loadSearchSettingsSummary(); } catch (err) { addProcessEntry('error', `Failed to set search rigor: ${err.message}`); } } function setQuickThinkingEffort(level) { quickThinkingEffort = level === 'extended' ? 'extended' : 'standard'; localStorage.setItem('homeclaw_quick_thinking_effort', quickThinkingEffort); updateQuickModeUI(); addProcessEntry('info', `Thinking effort set to ${quickThinkingEffort} (UI preference).`); } function setSettingsTab(tab) { settingsTab = tab; const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'voice', 'session', 'context-viewer']; tabs.forEach(t => { const btn = document.getElementById(`settings-tab-${t}`); const panel = document.getElementById(`settings-panel-${t}`); if (btn) { btn.style.background = (t === tab) ? '#eaf2ff' : '#fff'; btn.style.borderColor = (t === tab) ? '#bdd3f6' : 'var(--line)'; btn.style.color = (t === tab) ? '#0d4faf' : 'var(--muted)'; } if (panel) { if (t === tab) { panel.style.display = 'block'; if (t === 'channels') loadChannelsStatus(); if (t === 'models') loadModelSettings(); if (t === 'agents') loadAgentsTab(); if (t === 'integrations') loadIntegrationsTab(); if (t === 'credentials') loadCredentialsTab(); if (t === 'users') loadUsersTab(); if (t === 'voice') loadVoiceSettings(); if (t === 'session') loadSessionSettings(); if (t === 'context-viewer') { loadContextViewerTab(); loadBannedIpsTab(); } } else { panel.style.display = 'none'; } } }); } // --- Credentials Tab --------------------------------------------------------- async function loadCredentialsTab() { await Promise.all([loadCredFields(), loadCredVaultStatus(), loadCredVaultLog(), loadCredIssuers()]); } async function loadCredFields() { try { const s = await api('/api/settings/search'); // Server returns '••••••••' if key is set, '' if not const setField = (id, val) => { const el = document.getElementById(id); if (!el) return; el.value = val || ''; el.placeholder = val ? '•••••••• (key stored — enter new value to replace)' : el.getAttribute('data-placeholder') || ''; }; setField('cred-tavily-key', s.tavily_api_key); setField('cred-google-key', s.google_api_key); setField('cred-brave-key', s.brave_api_key); const cxEl = document.getElementById('cred-google-cx'); if (cxEl) cxEl.value = s.google_cx || ''; } catch(e) { console.warn('loadCredFields:', e); } // OpenWeather key is stored separately try { const ow = await api('/api/settings/openweather'); const owEl = document.getElementById('cred-ow-key'); if (owEl) { owEl.value = ow.api_key || ''; owEl.placeholder = ow.api_key ? '•••••••• (key stored — enter new value to replace)' : ''; } } catch(e) { console.warn('loadCredFields openweather:', e); } } async function loadCredVaultStatus() { const el = document.getElementById('cred-vault-status'); if (!el) return; try { const data = await api('/api/credentials/status'); const keys = data.keys || []; if (!keys.length) { el.innerHTML = '⚠ No credentials stored yet.'; return; } el.innerHTML = keys.map(k => { const label = { 'search.tavily_api_key': 'Tavily API Key', 'search.google_api_key': 'Google API Key', 'search.brave_api_key': 'Brave API Key', 'llm.openai.api_key': 'OpenAI API Key', 'hooks.token': 'Webhook Token', 'channels.telegram.botToken': 'Telegram Token', 'channels.discord.botToken': 'Discord Token', 'openweather.api_key': 'OpenWeather API Key', }[k] || k; return `
✓ ${label} ${k}
`; }).join(''); } catch(e) { el.innerHTML = `Could not load vault status: ${e.message}`; } } // Where each stored credential is issued/renewed. Keys are opaque strings in the vault and the // issuing site is nowhere in the UI, so renewing one meant hunting for the right console — and // data.go.kr in particular needs a *per-service* 활용신청, which is easy to forget (the AirKorea // 측정소정보 service 403'd for exactly that reason on 2026-08-09). const CRED_ISSUERS = [ { key: 'search.tavily_api_key', name: 'Tavily', url: 'https://app.tavily.com/home', note: '웹검색' }, { key: 'search.google_api_key', name: 'Google API', url: 'https://console.cloud.google.com/apis/credentials', note: '검색(CSE)' }, { key: 'search.brave_api_key', name: 'Brave Search', url: 'https://api-dashboard.search.brave.com/app/keys', note: '웹검색' }, { key: 'search.ollama_api_key', name: 'Ollama', url: 'https://ollama.com/settings/keys', note: '클라우드 모델·웹검색' }, { key: 'llm.google.api_key', name: 'Google AI Studio', url: 'https://aistudio.google.com/apikey', note: 'Gemini' }, { key: 'anthropic.api_key', name: 'Anthropic', url: 'https://console.anthropic.com/settings/keys', note: 'Claude' }, { key: 'llm.openai.api_key', name: 'OpenAI', url: 'https://platform.openai.com/api-keys', note: 'GPT' }, { key: 'openweather.api_key', name: 'OpenWeather', url: 'https://home.openweathermap.org/api_keys', note: '날씨' }, { key: 'kma.api_key', name: '공공데이터포털', url: 'https://www.data.go.kr/iim/api/selectAPIAcountView.do', note: '기상청·에어코리아 — 서비스별로 활용신청 필요' }, { key: 'cds.api_key', name: 'Copernicus CDS', url: 'https://cds.climate.copernicus.eu/profile', note: '기후 재분석' }, { key: 'news.newsdata_api_key', name: 'NewsData.io', url: 'https://newsdata.io/api-key', note: '뉴스' }, { key: 'pubmed.api_key', name: 'NCBI PubMed', url: 'https://account.ncbi.nlm.nih.gov/settings/', note: '논문' }, { key: 'traffic.its_api_key', name: 'ITS 국가교통정보', url: 'https://www.its.go.kr/opendata/', note: '교통' }, { key: 'ppt.unsplash_key', name: 'Unsplash', url: 'https://unsplash.com/oauth/applications', note: 'PPT 이미지' }, { key: 'ppt.pexels_key', name: 'Pexels', url: 'https://www.pexels.com/api/', note: 'PPT 이미지' }, { key: 'ppt.pixabay_key', name: 'Pixabay', url: 'https://pixabay.com/api/docs/', note: 'PPT 이미지' }, { key: 'channels.telegram.botToken', name: 'Telegram BotFather', url: 'https://t.me/BotFather', note: '텔레그램 봇' }, { key: 'channels.kakao.appKey', name: 'Kakao Developers', url: 'https://developers.kakao.com/console/app', note: '카카오' }, { key: 'writer.providers.aladin.apiKey', name: '알라딘 TTB', url: 'https://www.aladin.co.kr/ttb/wblog_manage.aspx', note: '도서 검색' }, ]; async function loadCredIssuers() { const el = document.getElementById('cred-issuers'); if (!el) return; let stored = new Set(); try { const data = await api('/api/credentials/status'); stored = new Set(data.keys || []); } catch (e) { // Listing the sites is still useful without the vault — just don't claim what is stored. el.innerHTML = `
저장 상태를 못 읽었습니다 (${escHtml(e.message)}) — 링크만 표시합니다.
`; } const rows = CRED_ISSUERS.map(s => { const has = stored.has(s.key); const mark = has ? '✓' : '·'; return `
${mark} ${escHtml(s.name)} ${escHtml(s.note)}
`; }).join(''); el.innerHTML = (el.innerHTML.includes('저장 상태를 못 읽었습니다') ? el.innerHTML : '') + rows; } async function loadCredVaultLog() { const el = document.getElementById('cred-vault-log'); if (!el) return; try { const data = await api('/api/credentials/audit'); const lines = (data.lines || []).slice(-18).reverse(); if (!lines.length) { el.textContent = '감사 항목 없음.'; return; } el.innerHTML = lines.map(l => { const safe = l.replace(/&/g,'&').replace(//g,'>'); const color = l.includes('SET') ? 'var(--ok)' : l.includes('GET') ? 'var(--brand)' : l.includes('DEL') ? 'var(--err)' : 'var(--muted)'; return `
${safe}
`; }).join(''); } catch(e) { el.textContent = '감사 로그를 불러올 수 없습니다.'; } } function toggleCredVis(inputId, btn) { const el = document.getElementById(inputId); if (!el) return; if (el.type === 'password') { el.type = 'text'; btn.textContent = '🙈'; // Auto-rehide after 8s setTimeout(() => { el.type = 'password'; btn.textContent = '👁'; }, 8000); } else { el.type = 'password'; btn.textContent = '👁'; } } // --- Provider-aware Model Settings ------------------------------------------ const PROVIDER_IDS = ['ollama', 'ollama_local', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic', 'google']; function onProviderChange() { const provider = document.getElementById('settings-llm-provider').value; // Show only the matching fields panel PROVIDER_IDS.forEach(id => { const el = document.getElementById('prov-fields-' + id); if (el) el.style.display = id === provider ? 'block' : 'none'; }); if (provider === 'openai') { refreshOpenAIModels(true).catch(() => {}); } else if (['ollama', 'lm_studio', 'llama_cpp'].includes(provider)) { refreshProviderModels().catch(() => {}); } syncOrchPreemptControls(); } async function loadModelSettings() { try { const data = await api('/api/settings/provider'); const llm = data?.llm || { provider: 'ollama', providers: {} }; const prov = llm.provider || 'ollama'; const provSel = document.getElementById('settings-llm-provider'); if (provSel) provSel.value = prov; // Only toggle visibility, skip model refresh here to avoid double-loading PROVIDER_IDS.forEach(id => { const el = document.getElementById('prov-fields-' + id); if (el) el.style.display = id === prov ? 'block' : 'none'; }); const pc = llm.providers || {}; // Populate each provider's fields from saved config const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; }; v('settings-ollama-endpoint', pc.ollama?.endpoint); v('settings-ollamalocal-endpoint', pc.ollama_local?.endpoint); // 지서버 is deliberately NOT auto-refreshed on provider change (that would fire a WOL packet // and wait ~45s just for picking an entry in a dropdown), so its and status line. Writing the fetched list into // the ollama panel's select regardless of provider is what used to happen, and for ollama_local it // meant the list was fetched, dropped into a hidden element, and never seen — the 지서버 model was // a free-text box you had to spell correctly by hand. const MODEL_SELECT_IDS = { ollama_local: 'settings-ollamalocal-model', lm_studio: 'settings-lmstudio-model', llama_cpp: 'settings-llamacpp-model', }; const PROVIDER_STATUS_IDS = { ollama_local: 'provider-status-msg-ollamalocal', lm_studio: 'provider-status-msg-lmstudio', llama_cpp: 'provider-status-msg-llamacpp', }; /** Make `val` a real, selected option of a is populated. if (sel && sel.tagName === 'SELECT') { // forcedValue: saved model passed from loadModelSettings (DOM value not yet set when options are empty) const prev = forcedValue || String(sel.value || '').trim(); // Ollama's /api/tags order is whatever it happens to be (pull order, alphabetical, // etc.) — the current/saved model isn't necessarily first, so opening the dropdown // shows some unrelated model at the top and it's easy to mis-click (reported // 2026-08-08: "nemotron", not the intended primary, kept getting picked because it // sorted first). Move the saved model to the front of the list so it's both the // selected value AND the first thing the user sees. let ordered = models; if (prev && models.includes(prev)) { ordered = [prev, ...models.filter(m => m !== prev)]; } sel.innerHTML = ordered.map(m => ``).join(''); if (ordered.length) sel.value = (prev && ordered.includes(prev)) ? prev : ordered[0]; } if (msg) { msg.textContent = models.length ? `${models.length}개 모델 발견.${data?.waking ? ' (깨우고 연결함)' : ''}` : (data?.waking ? '깨우는 중입니다 — 잠시 후 다시 시도해 주세요.' : '모델 없음 — 서버가 실행 중인가요?'); } } catch (e) { if (msg) msg.textContent = '모델 가져오기 실패.'; } } async function testProviderConnection() { const provider = document.getElementById('settings-llm-provider').value; const statusIds = { ollama: 'provider-status-msg', ollama_local: 'provider-status-msg-ollamalocal', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status', anthropic: 'provider-status-msg-anthropic', google: 'provider-status-msg-google' }; const msgId = statusIds[provider] || 'provider-status-msg'; const msg = document.getElementById(msgId); if (msg) msg.textContent = '테스트 중…'; if (provider === 'openai') { const ok = await refreshOpenAIModels(false); if (msg) msg.textContent = ok ? '✓ 연결됨' : (msg.textContent || '✗ 연결 실패'); return; } try { const llm = buildProviderPayload(); if (llm.provider !== provider) llm.provider = provider; const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) }); if (msg) msg.textContent = data?.success ? '✓ 연결됨' : '✗ ' + (data?.error || '연결 실패'); } catch (e) { if (msg) msg.textContent = '✗ ' + e.message; } } function updateOpenAIModelDropdown(models) { const sel = document.getElementById('settings-openai-model'); if (!sel) return; const current = String(sel.value || '').trim(); const unique = Array.from(new Set((models || []).filter(Boolean).map(m => String(m).trim()).filter(Boolean))); if (!unique.length) return; if (current && !unique.includes(current)) unique.unshift(current); sel.innerHTML = unique.slice(0, 500).map(m => ``).join(''); if (current) sel.value = current; } async function refreshOpenAIModels(silent = false) { const statusEl = document.getElementById('provider-status-msg-openai'); const apiKey = document.getElementById('settings-openai-key')?.value?.trim() || ''; if (!apiKey) { if (!silent && statusEl) statusEl.textContent = 'API 키를 먼저 입력하세요.'; return false; } if (!silent && statusEl) statusEl.textContent = '모델 목록 가져오는 중…'; try { const data = await api('/api/openai/models', { method: 'POST', body: JSON.stringify({ api_key: apiKey }) }); if (!data?.success) { if (statusEl) statusEl.textContent = '✗ ' + (data?.error || '모델 가져오기 실패'); return false; } const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m))); updateOpenAIModelDropdown(models); if (statusEl) statusEl.textContent = models.length ? `✓ ${models.length}개 모델 사용 가능` : '✓ 연결됨'; return true; } catch (e) { if (statusEl) statusEl.textContent = '✗ ' + e.message; return false; } } // Build the llm config object from current UI state function buildProviderPayload() { const provider = document.getElementById('settings-llm-provider')?.value || 'ollama'; const providers = {}; providers.ollama = { endpoint: document.getElementById('settings-ollama-endpoint')?.value || 'http://localhost:11434', model: document.getElementById('settings-primary-model')?.value || '' }; providers.ollama_local = { endpoint: document.getElementById('settings-ollamalocal-endpoint')?.value || '', model: document.getElementById('settings-ollamalocal-model')?.value || '' }; providers.llama_cpp = { endpoint: document.getElementById('settings-llamacpp-endpoint')?.value || 'http://localhost:8080', model: document.getElementById('settings-llamacpp-model')?.value || '' }; providers.lm_studio = { endpoint: document.getElementById('settings-lmstudio-endpoint')?.value || 'http://localhost:1234', model: document.getElementById('settings-lmstudio-model')?.value || '', disable_thinking: !!document.getElementById('settings-lmstudio-disable-thinking')?.checked }; providers.openai = { api_key: document.getElementById('settings-openai-key')?.value || '', model: document.getElementById('settings-openai-model')?.value || 'gpt-4o' }; providers.openai_codex = { model: document.getElementById('settings-codex-model')?.value || 'gpt-5.3-codex' }; providers.anthropic = { api_key: document.getElementById('settings-anthropic-key')?.value || '', model: document.getElementById('settings-anthropic-model')?.value || 'claude-sonnet-4-6' }; providers.google = { api_key: document.getElementById('settings-google-key')?.value || '', model: document.getElementById('settings-google-model')?.value || 'gemini-3.6-flash' }; return { provider, providers }; } // --- Codex OAuth UI ------------------------------------------------ async function refreshCodexStatus() { try { const data = await api('/api/auth/openai/status'); const disc = document.getElementById('codex-disconnected-state'); const conn = document.getElementById('codex-connected-state'); const acct = document.getElementById('codex-account-id'); if (data?.connected) { if (disc) disc.style.display = 'none'; if (conn) conn.style.display = 'block'; if (acct) acct.textContent = data.account_id ? `계정: ${data.account_id}` : '계정 연결됨'; } else { if (disc) disc.style.display = 'block'; if (conn) conn.style.display = 'none'; } } catch {} } async function startCodexOAuth() { const statusEl = document.getElementById('codex-oauth-status'); if (statusEl) statusEl.textContent = '브라우저 열기 중…'; try { const data = await api('/api/auth/openai/start', { method: 'POST', body: '{}' }); if (data?.needsManualPaste) { if (statusEl) statusEl.innerHTML = 'Browser did not open. Click here to authorize, then paste the redirect URL below.
'; } else if (data?.success) { if (statusEl) statusEl.textContent = ''; await refreshCodexStatus(); } else { if (statusEl) statusEl.textContent = '? ' + (data?.error || 'OAuth failed'); } } catch (e) { if (statusEl) statusEl.textContent = '✗ ' + e.message; } } async function submitManualCodexUrl() { const url = document.getElementById('codex-manual-url')?.value?.trim(); if (!url) return; const statusEl = document.getElementById('codex-oauth-status'); if (statusEl) statusEl.textContent = '토큰 교환 중…'; try { const data = await api('/api/auth/openai/manual', { method: 'POST', body: JSON.stringify({ url }) }); if (data?.success) { if (statusEl) statusEl.textContent = ''; await refreshCodexStatus(); } else { if (statusEl) statusEl.textContent = '? ' + (data?.error || 'Failed'); } } catch (e) { if (statusEl) statusEl.textContent = '✗ ' + e.message; } } async function disconnectCodex() { await api('/api/auth/openai/disconnect', { method: 'POST', body: '{}' }); await refreshCodexStatus(); } // Legacy alias so any old references still work async function refreshOllamaModels() { await refreshProviderModels(); } async function loadAgentSessionContext() { try { if (!agentSessionId) setAgentSessionId(activeChatSessionId); const sid = encodeURIComponent(agentSessionId); const data = await api(`/api/agent/session/${sid}`); const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; set('r-session-mode', data.mode_lock || '-'); set('r-agent-state', `${data.mode || '-'} / ${lastTurnKind !== '-' ? lastTurnKind : 'n/a'}`); set('r-overview-objective', (data.overview_objective || '(none)').slice(0, 56)); set('r-active-objective', (data.active_objective || '(none)').slice(0, 56)); const tc = data.task_counts || {}; const tr = data.turn_counts || {}; const ec = data.execution_counts || {}; const useExecutionCounts = Number(ec.total || 0) > 0 || data.current_turn_execution; if (useExecutionCounts) { const doneExec = Number(ec.done || 0) + Number(ec.repaired || 0); set('r-task-counts', `${doneExec}/${ec.total || 0} completed`); set('r-turn-counts', `${ec.running || 0} running • ${ec.failed || 0} failed`); } else { set('r-task-counts', `${tc.done || 0}/${tc.total || 0} done`); set('r-turn-counts', `${tr.completed || 0} done • ${tr.open || 0} open`); } const total = useExecutionCounts ? Number(ec.total || 0) : Number(tc.total || 0); const done = useExecutionCounts ? (Number(ec.done || 0) + Number(ec.repaired || 0)) : Number(tc.done || 0); const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0; const bar = document.getElementById('r-task-progress-bar'); if (bar) bar.style.width = `${pct}%`; const execPanel = document.getElementById('r-turn-execution'); if (execPanel) { const cur = data.current_turn_execution; if (!cur || typeof cur !== 'object') { execPanel.innerHTML = '
활성 턴 실행 없음.
'; } else { const objective = escHtml(String(cur.objective_raw || '(no objective)').slice(0, 220)); const status = escHtml(String(cur.status || 'planned')); const mode = escHtml(String(cur.mode || 'unknown')); const turnKind = escHtml(String(cur.turn_kind || 'unknown')); const steps = Array.isArray(cur.steps) ? cur.steps : []; const verification = cur.verification && typeof cur.verification === 'object' ? cur.verification : null; const trace = cur.decision_trace && typeof cur.decision_trace === 'object' ? cur.decision_trace : null; const traceEvents = Array.isArray(trace?.events) ? trace.events : []; const stepRows = steps.map((s, idx) => { const stepTitle = escHtml(String(s.title || s.type || `Step ${idx + 1}`)); const stepStatus = escHtml(String(s.status || 'pending')); const outputs = s.outputs && typeof s.outputs === 'object' ? `
${escHtml(JSON.stringify(s.outputs).slice(0, 160))}
` : ''; return `
${idx + 1}. ${stepTitle}
${stepStatus.replace('_', ' ')}
${outputs}
`; }).join(''); const verifyHtml = verification ? `
verify: ${escHtml(String(verification.status || 'pass'))}${Array.isArray(verification.repairs_applied) && verification.repairs_applied.length ? ` • repairs: ${verification.repairs_applied.length}` : ''}${Array.isArray(verification.errors) && verification.errors.length ? ` • errors: ${verification.errors.length}` : ''}
` : '
verify: (not run)
'; const traceHtml = traceEvents.length ? `
Decision Trace
${traceEvents.length}
${traceEvents.slice(-10).map((ev) => { const stage = escHtml(String(ev?.stage || 'trace')); const msg = escHtml(String(ev?.message || '').slice(0, 180)); return `
[${stage}] ${msg}
`; }).join('')}
` : ''; execPanel.innerHTML = `
Current Turn
${status.replace('_', ' ')}
${objective}
mode: ${mode} • kind: ${turnKind}
${verifyHtml}
${stepRows || '
No execution steps.
'} ${traceHtml} `; } } const taskList = document.getElementById('r-task-list'); if (taskList) { const tasks = Array.isArray(data.tasks) ? data.tasks : []; if (!tasks.length) { taskList.innerHTML = '
작업 없음.
'; } else { taskList.innerHTML = tasks.slice(0, 20).map(t => { const title = escHtml(String(t.title || '').slice(0, 120)); const status = escHtml(String(t.status || 'pending')); const tool = escHtml(String(t.tool || 'tool')); return `
${title}
${status.replace('_', ' ')}
tool: ${tool}
`; }).join(''); } } const rt = document.getElementById('r-recent-turns'); if (rt) { const recentExec = Array.isArray(data.recent_turn_executions) ? data.recent_turn_executions : []; if (recentExec.length) { rt.innerHTML = recentExec.slice(0, 10).map((e, idx) => { const status = escHtml(String(e.status || 'planned')); const objective = escHtml(String(e.objective_raw || '').slice(0, 120)); const tools = Array.isArray(e.tool_calls) ? e.tool_calls.length : 0; const traceCount = Array.isArray(e?.decision_trace?.events) ? e.decision_trace.events.length : 0; const repairs = Array.isArray(e?.verification?.repairs_applied) ? e.verification.repairs_applied.length : 0; return `
#${idx + 1} [${status}] ${objective || '(objective)'}
tools: ${tools}${traceCount ? ` • trace: ${traceCount}` : ''}${repairs ? ` • repairs: ${repairs}` : ''}
`; }).join(''); } else { const turns = data.recent_turns || []; if (!turns.length) { rt.innerHTML = 'No turn objectives yet.'; } else { rt.innerHTML = turns.map(t => { const kind = escHtml(String(t.kind || 'turn')); const status = escHtml(String(t.status || 'open')); const text = escHtml(String(t.text || '').slice(0, 120)); return `
[${kind}/${status}] ${text}
`; }).join(''); } } } const toggle = document.getElementById('agent-mode-toggle'); const label = document.getElementById('agent-mode-label'); if (toggle) { const locked = data.mode_lock === 'agent' || data.mode_lock === 'chat'; toggle.disabled = !!locked; if (data.mode_lock === 'agent') { toggle.checked = true; useAgentMode = true; } else if (data.mode_lock === 'chat') { toggle.checked = false; useAgentMode = false; } } if (label) { label.textContent = useAgentMode ? '에이전트' : '채팅'; } maybeAutoScrollRightColumn(rightColumnAutoFollow && isThinking); } catch { const rt = document.getElementById('r-recent-turns'); if (rt) rt.innerHTML = 'Unable to load session context.'; } } function renderLiveStepsPanel() { const el = document.getElementById('r-live-steps'); if (!el) return; if (!liveTaskSteps.length) { el.innerHTML = '
활성 도구 단계 없음.
'; return; } el.innerHTML = liveTaskSteps.slice(-20).map((s) => { const action = escHtml(String(s.action || 'tool')); const status = escHtml(String(s.status || 'pending')); const stepNum = Number(s.stepNum || 0); const meta = s.meta ? `
${escHtml(String(s.meta).slice(0, 140))}
` : ''; return `
${stepNum ? `Step ${stepNum}: ` : ''}${action}
${status.replace('_', ' ')}
${meta}
`; }).join(''); } function upsertLiveStep(action, stepNum, status, meta = '') { const key = `${String(action || '')}:${Number(stepNum || 0)}`; const idx = liveTaskSteps.findIndex(s => s.key === key); const row = { key, action: String(action || ''), stepNum: Number(stepNum || 0), status: String(status || 'pending'), meta: String(meta || '') }; if (idx >= 0) liveTaskSteps[idx] = { ...liveTaskSteps[idx], ...row }; else liveTaskSteps.push(row); renderLiveStepsPanel(); } async function openSettings(tab) { document.getElementById('settings-modal').style.display = 'flex'; setSettingsTab(tab || settingsTab || 'system'); try { const status = await api('/api/status'); document.getElementById('settings-runtime-model').textContent = status.currentModel || '-'; loadOrchestrationConfig(); document.getElementById('settings-runtime-gateway').textContent = status.gateway || '-'; document.getElementById('settings-runtime-ollama').textContent = status.ollama ? 'Online' : 'Offline'; } catch {} try { const paths = await api('/api/settings/paths'); document.getElementById('settings-workspace-path').value = paths.workspace_path || ''; document.getElementById('settings-allowed-paths').value = (paths.allowed_paths || []).join('\n'); document.getElementById('settings-blocked-paths').value = (paths.blocked_paths || []).join('\n'); } catch {} try { const s = await api('/api/settings/search'); document.getElementById('settings-provider').value = s.preferred_provider || 'ddg'; document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified'; if (document.getElementById('settings-search-ollama-model')) document.getElementById('settings-search-ollama-model').value = s.ollama_model || ''; if (document.getElementById('settings-search-searxng-url')) document.getElementById('settings-search-searxng-url').value = s.searxng_url || ''; toggleOllamaModelRow(s.preferred_provider); toggleSearxngUrlRow(s.preferred_provider); _searchSettingsLoaded = true; // Keys are loaded via the Credentials tab — not here } catch {} try { const p = await api('/api/settings/agent'); document.getElementById('settings-force-web-fresh').checked = p.force_web_for_fresh !== false; document.getElementById('settings-memory-fallback').checked = p.memory_fallback_on_search_failure !== false; document.getElementById('settings-auto-store-web-facts').checked = p.auto_store_web_facts !== false; document.getElementById('settings-nl-tool-router').checked = p.natural_language_tool_router !== false; document.getElementById('settings-retrieval-mode').value = p.retrieval_mode || 'standard'; } catch {} } // --- Users Tab --------------------------------------------------------- async function loadUsersTab() { const container = document.getElementById('users-list'); if (!container) return; try { const data = await api('/api/users'); if (!data || !data.users) { container.innerHTML = '
인증이 비활성화되어 있거나 권한이 없습니다.
'; return; } if (data.users.length === 0) { container.innerHTML = '
등록된 사용자가 없습니다.
'; return; } container.innerHTML = data.users.map(u => `
${u.username.charAt(0).toUpperCase()}
${u.username} (${u.role})
${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''} ${u.username !== data.current_user ? `` : '현재'}
`).join(''); } catch (e) { container.innerHTML = '
사용자 목록을 불러올 수 없습니다.
'; } } async function addUser() { const username = document.getElementById('new-user-username').value.trim(); const password = document.getElementById('new-user-password').value; const role = document.getElementById('new-user-role').value; const errEl = document.getElementById('add-user-error'); errEl.classList.remove('visible'); try { const data = await api('/api/users', { method: 'POST', body: JSON.stringify({ username, password, role }) }); if (data.success) { document.getElementById('new-user-username').value = ''; document.getElementById('new-user-password').value = ''; loadUsersTab(); } else { errEl.textContent = data.error || '사용자 추가 실패'; errEl.classList.add('visible'); } } catch (e) { errEl.textContent = e.message || '사용자 추가 실패'; errEl.classList.add('visible'); } } async function removeUser(username) { if (!confirm(`사용자 "${username}"를 삭제하시겠습니까?`)) return; try { await api(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' }); loadUsersTab(); } catch (e) { alert(e.message || '삭제 실패'); } } const PROVIDER_LABELS = { ollama: 'Ollama', llama_cpp: 'llama.cpp', lm_studio: 'LM Studio', openai: 'OpenAI', openai_codex: 'ChatGPT', anthropic: 'Anthropic', google: 'Google' }; async function checkStatus() { try { const status = await api('/api/status'); const provider = status.provider || 'ollama'; const providerLabel = PROVIDER_LABELS[provider] || provider; const isCloud = provider === 'openai' || provider === 'openai_codex'; const dot = document.getElementById('ollama-dot'); // For cloud providers, always show as online if gateway is up const backendOnline = isCloud ? true : !!status.ollama; // Asleep-but-wakeable is its own state: a message wakes the box and is answered, so // showing the red "Offline" made a working setup look broken. const sleeping = !backendOnline && !!status.sleeping; const state = backendOnline ? 'online' : (sleeping ? 'sleeping' : 'offline'); dot.className = 'dot ' + state; const stateLabel = backendOnline ? 'Online' : (sleeping ? '절전 중' : 'Offline'); const statusEl = document.getElementById('ollama-status'); // "Online" told you nothing the green dot didn't already: ollama_local always resolved to // Online (cloud providers are hardcoded online above), so the sidebar pill permanently read // "ollama_local Online" / "OpenAI Online" and, being long, wrapped the narrow sidebar onto // its own line. Only show a state word when it's not the assumed-good state — the dot alone // carries "online", same as {red,yellow} still get called out because a color alone is easy // to miss. statusEl.textContent = backendOnline ? providerLabel : `${providerLabel} ${stateLabel}`; statusEl.title = sleeping ? '유휴 상태로 절전 중입니다. 메시지를 보내면 자동으로 깨어납니다.' : ''; const ro = document.getElementById('r-ollama'); if (ro) { ro.textContent = stateLabel; ro.className = 'info-val ' + (backendOnline ? 'green' : (sleeping ? 'yellow' : 'red')); ro.title = statusEl.title; } const modelName = status.currentModel || '-'; const rm = document.getElementById('r-model'); if (rm) rm.textContent = modelName; document.getElementById('current-model').textContent = modelName; document.getElementById('chat-model-name').textContent = modelName; const badge = document.getElementById('current-provider-badge'); if (badge) badge.textContent = isCloud ? providerLabel.toUpperCase() : ''; const cpn = document.getElementById('chat-provider-name'); if (cpn) cpn.textContent = providerLabel; // Google-only: daily request usage against the free-tier RPD limit const gub = document.getElementById('google-usage-badge'); if (gub) { const gu = status.googleUsage; if (provider === 'google' && gu) { const pct = gu.limit > 0 ? (gu.count / gu.limit) : 0; gub.style.display = 'inline-block'; gub.textContent = `${gu.count}/${gu.limit}`; gub.style.color = pct >= 0.9 ? 'var(--err)' : pct >= 0.7 ? 'var(--warn)' : 'var(--muted)'; } else { gub.style.display = 'none'; } } } catch { document.getElementById('ollama-dot').className = 'dot offline'; } } // --- Orchestration Settings ------------------------------------------------- const SECONDARY_MODEL_DEFAULTS = { openai_codex: 'gpt-5.3-codex', openai: 'gpt-4o', ollama: '', llama_cpp: '', lm_studio: '', }; const ORCH_STATIC_MODEL_OPTIONS = { openai_codex: ['gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2-codex', 'gpt-5.1-codex-mini'], openai: ['gpt-4o', 'gpt-4o-mini', 'o4-mini', 'o3'], }; function getSelectOptionValues(selectId) { const sel = document.getElementById(selectId); if (!sel) return []; return Array.from(sel.options || []).map(o => String(o.value || '').trim()).filter(Boolean); } function getDraftModelForProvider(provider) { if (provider === 'ollama') { return String(document.getElementById('settings-primary-model')?.value || '').trim(); } if (provider === 'llama_cpp') { return String(document.getElementById('settings-llamacpp-model')?.value || '').trim(); } if (provider === 'lm_studio') { return String(document.getElementById('settings-lmstudio-model')?.value || '').trim(); } if (provider === 'openai') { return String(document.getElementById('settings-openai-model')?.value || '').trim(); } if (provider === 'openai_codex') { return String(document.getElementById('settings-codex-model')?.value || '').trim(); } return ''; } function uniqueStrings(values) { const seen = new Set(); const out = []; for (const v of values || []) { const s = String(v || '').trim(); if (!s || seen.has(s)) continue; seen.add(s); out.push(s); } return out; } async function updateOrchSecondaryModels(preferredModel = '') { const provider = document.getElementById('orch-secondary-provider')?.value || ''; const modelSelect = document.getElementById('orch-secondary-model'); if (!modelSelect) return; if (!provider) { modelSelect.innerHTML = ''; modelSelect.value = ''; return; } let models = []; const draftModel = getDraftModelForProvider(provider); if (draftModel) models.push(draftModel); if (provider === 'openai') { try { await refreshOpenAIModels(true); } catch {} models.push(...getSelectOptionValues('settings-openai-model')); } else if (provider === 'openai_codex') { models.push(...getSelectOptionValues('settings-codex-model')); } else if (['ollama', 'llama_cpp', 'lm_studio'].includes(provider)) { try { const llm = buildProviderPayload(); llm.provider = provider; const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) }); const detected = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m))); models.push(...detected); } catch {} } models = uniqueStrings([...(ORCH_STATIC_MODEL_OPTIONS[provider] || []), ...models]); if (preferredModel && !models.includes(preferredModel)) { models.unshift(preferredModel); } if (!models.length && SECONDARY_MODEL_DEFAULTS[provider]) { models.push(SECONDARY_MODEL_DEFAULTS[provider]); } if (!models.length) { modelSelect.innerHTML = ''; modelSelect.value = ''; return; } modelSelect.innerHTML = models.map(m => ``).join(''); const selected = preferredModel || modelSelect.value || draftModel || SECONDARY_MODEL_DEFAULTS[provider] || models[0]; modelSelect.value = models.includes(selected) ? selected : models[0]; } let orchActivePreset = ''; const ORCH_PRESETS = { conservative: { desc: '오케스트레이터가 최소한으로 개입 — 안정적이지만 느리게 반응', fail_threshold: 3, stagnation: 5, loop_detect: 'true', risk_files: 10, risk_ops: 400, no_progress_sec: 150, preflight: 'off', allow_secondary_chat: 'false', cooldown: 5, max_assists_turn: 2, max_assists_session: 10, browser_calls_turn: 3, browser_max_items: 40, browser_forced_retries: 0, preempt_enabled: 'false', preempt_threshold: 90, preempt_max_turn: 1, preempt_max_session: 1, fileops_enabled: 'true', fileops_verify_create: 'false', fileops_create_lines: 150, fileops_create_chars: 7000, fileops_edit_lines: 25, fileops_edit_chars: 2000, fileops_edit_files: 2, fileops_verify_lines: 50, fileops_verify_chars: 2500, fileops_watchdog: 5, fileops_checkpointing: 'true', }, balanced: { desc: '기본 권장값 — 대부분의 상황에 적합', fail_threshold: 2, stagnation: 3, loop_detect: 'true', risk_files: 6, risk_ops: 220, no_progress_sec: 90, preflight: 'complex_only', allow_secondary_chat: 'false', cooldown: 3, max_assists_turn: 3, max_assists_session: 18, browser_calls_turn: 5, browser_max_items: 80, browser_forced_retries: 0, preempt_enabled: 'false', preempt_threshold: 45, preempt_max_turn: 1, preempt_max_session: 3, fileops_enabled: 'true', fileops_verify_create: 'true', fileops_create_lines: 80, fileops_create_chars: 3500, fileops_edit_lines: 12, fileops_edit_chars: 800, fileops_edit_files: 1, fileops_verify_lines: 25, fileops_verify_chars: 1200, fileops_watchdog: 3, fileops_checkpointing: 'true', }, aggressive: { desc: '자주 적극적으로 개입 — 복잡한 멀티스텝 작업에 적합', fail_threshold: 1, stagnation: 2, loop_detect: 'true', risk_files: 3, risk_ops: 100, no_progress_sec: 45, preflight: 'always', allow_secondary_chat: 'false', cooldown: 2, max_assists_turn: 5, max_assists_session: 30, browser_calls_turn: 8, browser_max_items: 120, browser_forced_retries: 2, preempt_enabled: 'true', preempt_threshold: 30, preempt_max_turn: 2, preempt_max_session: 6, fileops_enabled: 'true', fileops_verify_create: 'true', fileops_create_lines: 40, fileops_create_chars: 1500, fileops_edit_lines: 6, fileops_edit_chars: 400, fileops_edit_files: 1, fileops_verify_lines: 15, fileops_verify_chars: 600, fileops_watchdog: 2, fileops_checkpointing: 'true', }, }; function applyOrchPreset(name) { const p = ORCH_PRESETS[name]; if (!p) return; orchActivePreset = name; const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; set('orch-fail-threshold', p.fail_threshold); set('orch-stagnation', p.stagnation); set('orch-loop-detect', p.loop_detect); set('orch-risk-files', p.risk_files); set('orch-risk-ops', p.risk_ops); set('orch-no-progress-sec', p.no_progress_sec); set('orch-preflight-mode', p.preflight); set('orch-allow-secondary-chat', p.allow_secondary_chat); set('orch-cooldown-rounds', p.cooldown); set('orch-max-assists-turn', p.max_assists_turn); set('orch-max-assists-session', p.max_assists_session); set('orch-browser-calls-turn', p.browser_calls_turn); set('orch-browser-max-items', p.browser_max_items); set('orch-browser-forced-retries', p.browser_forced_retries); set('orch-preempt-enabled', p.preempt_enabled); set('orch-preempt-threshold', p.preempt_threshold); set('orch-preempt-max-turn', p.preempt_max_turn); set('orch-preempt-max-session', p.preempt_max_session); set('orch-fileops-enabled', p.fileops_enabled); set('orch-fileops-verify-create', p.fileops_verify_create); set('orch-fileops-create-lines', p.fileops_create_lines); set('orch-fileops-create-chars', p.fileops_create_chars); set('orch-fileops-edit-lines', p.fileops_edit_lines); set('orch-fileops-edit-chars', p.fileops_edit_chars); set('orch-fileops-edit-files', p.fileops_edit_files); set('orch-fileops-verify-lines', p.fileops_verify_lines); set('orch-fileops-verify-chars', p.fileops_verify_chars); set('orch-fileops-watchdog-cycles', p.fileops_watchdog); set('orch-fileops-checkpointing', p.fileops_checkpointing); // highlight active button ['conservative','balanced','aggressive'].forEach(n => { const btn = document.getElementById('orch-preset-' + n); if (!btn) return; btn.style.background = n === name ? '#eaf2ff' : '#fff'; btn.style.borderColor = n === name ? '#0d4faf' : 'var(--line)'; btn.style.color = n === name ? '#0d4faf' : '#5a6e8a'; }); const desc = document.getElementById('orch-preset-desc'); if (desc) desc.textContent = p.desc; } function detectOrchPreset(cfg) { const t = cfg.triggers || {}; const l = cfg.limits || {}; if (t.consecutive_failures <= 1 && t.stagnation_rounds <= 2) return 'aggressive'; if (t.consecutive_failures >= 3 && t.stagnation_rounds >= 5) return 'conservative'; return 'balanced'; } function toggleOrchAdvanced() { const panel = document.getElementById('orch-advanced-panel'); const arrow = document.getElementById('orch-advanced-arrow'); if (!panel) return; const open = panel.style.display !== 'none'; panel.style.display = open ? 'none' : 'block'; if (arrow) arrow.style.transform = open ? '' : 'rotate(90deg)'; } // ── Voice Settings ─────────────────────────────────────────────────────────── async function loadVoiceSettings() { try { const cfg = await api('/api/settings/voice'); const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; }; v('voice-stt-provider', cfg.stt?.provider); v('voice-stt-model', cfg.stt?.model); v('voice-stt-language', cfg.stt?.language); v('voice-tts-provider', cfg.tts?.provider); v('voice-tts-model', cfg.tts?.model); } catch (e) { console.warn('loadVoiceSettings:', e); } } async function saveVoiceSettings() { const body = { stt: { provider: document.getElementById('voice-stt-provider')?.value, model: document.getElementById('voice-stt-model')?.value?.trim(), language: document.getElementById('voice-stt-language')?.value?.trim(), }, tts: { provider: document.getElementById('voice-tts-provider')?.value, model: document.getElementById('voice-tts-model')?.value?.trim(), }, }; try { await api('/api/settings/voice', { method: 'POST', body: JSON.stringify(body) }); addProcessEntry('final', '음성 설정이 저장되었습니다.'); } catch (err) { addProcessEntry('error', '음성 설정 저장 실패: ' + err.message); } } // ── Session Settings ───────────────────────────────────────────────────────── async function loadSessionSettings() { try { const cfg = await api('/api/settings/session'); const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = String(val); }; v('session-max-messages', cfg.maxMessages); v('session-history-turns', cfg.historyTurns); v('session-compaction', cfg.compactionThreshold); v('session-memory-flush', cfg.memoryFlushThreshold); } catch (e) { console.warn('loadSessionSettings:', e); } } async function saveSessionSettings() { const body = { maxMessages: Number(document.getElementById('session-max-messages')?.value || 120), historyTurns: Number(document.getElementById('session-history-turns')?.value || 8), compactionThreshold: Number(document.getElementById('session-compaction')?.value || 0.7), memoryFlushThreshold: Number(document.getElementById('session-memory-flush')?.value || 0.75), }; try { await api('/api/settings/session', { method: 'POST', body: JSON.stringify(body) }); addProcessEntry('final', '세션 설정이 저장되었습니다.'); } catch (err) { addProcessEntry('error', '세션 설정 저장 실패: ' + err.message); } } async function loadOrchestrationConfig() { try { const cfg = await api('/api/orchestration/config'); const provEl = document.getElementById('orch-secondary-provider'); const failEl = document.getElementById('orch-fail-threshold'); const stagEl = document.getElementById('orch-stagnation'); const loopEl = document.getElementById('orch-loop-detect'); const riskFilesEl = document.getElementById('orch-risk-files'); const riskOpsEl = document.getElementById('orch-risk-ops'); const noProgressEl = document.getElementById('orch-no-progress-sec'); const preflightModeEl = document.getElementById('orch-preflight-mode'); const allowSecondaryChatEl = document.getElementById('orch-allow-secondary-chat'); const cooldownEl = document.getElementById('orch-cooldown-rounds'); const maxAssistTurnEl = document.getElementById('orch-max-assists-turn'); const maxAssistSessionEl = document.getElementById('orch-max-assists-session'); const browserCallsEl = document.getElementById('orch-browser-calls-turn'); const browserItemsEl = document.getElementById('orch-browser-max-items'); const browserRetriesEl = document.getElementById('orch-browser-forced-retries'); const preemptEnabledEl = document.getElementById('orch-preempt-enabled'); const preemptThresholdEl = document.getElementById('orch-preempt-threshold'); const preemptMaxTurnEl = document.getElementById('orch-preempt-max-turn'); const preemptMaxSessionEl = document.getElementById('orch-preempt-max-session'); const fileOpsEnabledEl = document.getElementById('orch-fileops-enabled'); const fileOpsVerifyCreateEl = document.getElementById('orch-fileops-verify-create'); const fileOpsCreateLinesEl = document.getElementById('orch-fileops-create-lines'); const fileOpsCreateCharsEl = document.getElementById('orch-fileops-create-chars'); const fileOpsEditLinesEl = document.getElementById('orch-fileops-edit-lines'); const fileOpsEditCharsEl = document.getElementById('orch-fileops-edit-chars'); const fileOpsEditFilesEl = document.getElementById('orch-fileops-edit-files'); const fileOpsVerifyLinesEl = document.getElementById('orch-fileops-verify-lines'); const fileOpsVerifyCharsEl = document.getElementById('orch-fileops-verify-chars'); const fileOpsWatchdogCyclesEl = document.getElementById('orch-fileops-watchdog-cycles'); const fileOpsCheckpointingEl = document.getElementById('orch-fileops-checkpointing'); if (provEl) provEl.value = cfg.secondary?.provider || ''; await updateOrchSecondaryModels(cfg.secondary?.model || ''); if (failEl) failEl.value = String(cfg.triggers?.consecutive_failures ?? 2); if (stagEl) stagEl.value = String(cfg.triggers?.stagnation_rounds ?? 3); if (loopEl) loopEl.value = String(cfg.triggers?.loop_detection ?? true); if (riskFilesEl) riskFilesEl.value = String(cfg.triggers?.risky_files_threshold ?? 6); if (riskOpsEl) riskOpsEl.value = String(cfg.triggers?.risky_tool_ops_threshold ?? 220); if (noProgressEl) noProgressEl.value = String(cfg.triggers?.no_progress_seconds ?? 90); if (preflightModeEl) preflightModeEl.value = String(cfg.preflight?.mode || 'complex_only'); if (allowSecondaryChatEl) allowSecondaryChatEl.value = String(cfg.preflight?.allow_secondary_chat === true); if (cooldownEl) cooldownEl.value = String(cfg.limits?.assist_cooldown_rounds ?? 3); if (maxAssistTurnEl) maxAssistTurnEl.value = String(cfg.limits?.max_assists_per_turn ?? 3); if (maxAssistSessionEl) maxAssistSessionEl.value = String(cfg.limits?.max_assists_per_session ?? 18); if (browserCallsEl) browserCallsEl.value = String(cfg.browser?.max_advisor_calls_per_turn ?? 5); if (browserItemsEl) browserItemsEl.value = String(cfg.browser?.max_collected_items ?? 80); if (browserRetriesEl) browserRetriesEl.value = String(cfg.browser?.max_forced_retries ?? 2); if (preemptEnabledEl) preemptEnabledEl.value = String(cfg.preempt?.enabled === true); if (preemptThresholdEl) preemptThresholdEl.value = String(cfg.preempt?.stall_threshold_seconds ?? 45); if (preemptMaxTurnEl) preemptMaxTurnEl.value = String(cfg.preempt?.max_preempts_per_turn ?? 1); if (preemptMaxSessionEl) preemptMaxSessionEl.value = String(cfg.preempt?.max_preempts_per_session ?? 3); if (fileOpsEnabledEl) fileOpsEnabledEl.value = String(cfg.file_ops?.enabled !== false); if (fileOpsVerifyCreateEl) fileOpsVerifyCreateEl.value = String(cfg.file_ops?.verify_create_always !== false); if (fileOpsCreateLinesEl) fileOpsCreateLinesEl.value = String(cfg.file_ops?.primary_create_max_lines ?? 80); if (fileOpsCreateCharsEl) fileOpsCreateCharsEl.value = String(cfg.file_ops?.primary_create_max_chars ?? 3500); if (fileOpsEditLinesEl) fileOpsEditLinesEl.value = String(cfg.file_ops?.primary_edit_max_lines ?? 12); if (fileOpsEditCharsEl) fileOpsEditCharsEl.value = String(cfg.file_ops?.primary_edit_max_chars ?? 800); if (fileOpsEditFilesEl) fileOpsEditFilesEl.value = String(cfg.file_ops?.primary_edit_max_files ?? 1); if (fileOpsVerifyLinesEl) fileOpsVerifyLinesEl.value = String(cfg.file_ops?.verify_large_payload_lines ?? 25); if (fileOpsVerifyCharsEl) fileOpsVerifyCharsEl.value = String(cfg.file_ops?.verify_large_payload_chars ?? 1200); if (fileOpsWatchdogCyclesEl) fileOpsWatchdogCyclesEl.value = String(cfg.file_ops?.watchdog_no_progress_cycles ?? 3); if (fileOpsCheckpointingEl) fileOpsCheckpointingEl.value = String(cfg.file_ops?.checkpointing_enabled !== false); syncOrchPreemptControls(); // Auto-detect and highlight matching preset applyOrchPreset(detectOrchPreset(cfg)); // Check eligibility const elig = await api('/api/orchestration/eligible'); const msg = document.getElementById('orch-eligibility-msg'); if (msg) { if (elig.eligible) { msg.textContent = '? Orchestration eligible — enable the skill in the Skills tab.'; msg.style.color = 'var(--green, #4ade80)'; } else { msg.textContent = elig.reason || 'Not eligible.'; msg.style.color = 'var(--muted)'; } msg.style.display = 'block'; } } catch {} } async function saveOrchestrationConfig() { const provider = document.getElementById('orch-secondary-provider')?.value || ''; const model = document.getElementById('orch-secondary-model')?.value || ''; const failT = Number(document.getElementById('orch-fail-threshold')?.value || 2); const stagT = Number(document.getElementById('orch-stagnation')?.value || 3); const loopD = document.getElementById('orch-loop-detect')?.value !== 'false'; const riskFiles = Number(document.getElementById('orch-risk-files')?.value || 6); const riskOps = Number(document.getElementById('orch-risk-ops')?.value || 220); const noProgressSeconds = Number(document.getElementById('orch-no-progress-sec')?.value || 90); const preflightMode = document.getElementById('orch-preflight-mode')?.value || 'complex_only'; const allowSecondaryChat = document.getElementById('orch-allow-secondary-chat')?.value === 'true'; const cooldownRounds = Number(document.getElementById('orch-cooldown-rounds')?.value || 3); const maxAssistsTurn = Number(document.getElementById('orch-max-assists-turn')?.value || 3); const maxAssistsSession = Number(document.getElementById('orch-max-assists-session')?.value || 18); const browserCallsPerTurn = Number(document.getElementById('orch-browser-calls-turn')?.value || 5); const browserMaxItems = Number(document.getElementById('orch-browser-max-items')?.value || 80); const browserForcedRetries = Number(document.getElementById('orch-browser-forced-retries')?.value || 2); const preemptEnabled = document.getElementById('orch-preempt-enabled')?.value === 'true'; const preemptThresholdSec = Number(document.getElementById('orch-preempt-threshold')?.value || 45); const preemptMaxTurn = Number(document.getElementById('orch-preempt-max-turn')?.value || 1); const preemptMaxSession = Number(document.getElementById('orch-preempt-max-session')?.value || 3); const fileOpsEnabled = document.getElementById('orch-fileops-enabled')?.value !== 'false'; const fileOpsVerifyCreate = document.getElementById('orch-fileops-verify-create')?.value !== 'false'; const fileOpsCreateLines = Number(document.getElementById('orch-fileops-create-lines')?.value || 80); const fileOpsCreateChars = Number(document.getElementById('orch-fileops-create-chars')?.value || 3500); const fileOpsEditLines = Number(document.getElementById('orch-fileops-edit-lines')?.value || 12); const fileOpsEditChars = Number(document.getElementById('orch-fileops-edit-chars')?.value || 800); const fileOpsEditFiles = Number(document.getElementById('orch-fileops-edit-files')?.value || 1); const fileOpsVerifyLines = Number(document.getElementById('orch-fileops-verify-lines')?.value || 25); const fileOpsVerifyChars = Number(document.getElementById('orch-fileops-verify-chars')?.value || 1200); const fileOpsWatchdogCycles = Number(document.getElementById('orch-fileops-watchdog-cycles')?.value || 3); const fileOpsCheckpointing = document.getElementById('orch-fileops-checkpointing')?.value !== 'false'; try { await api('/api/orchestration/config', { method: 'POST', body: JSON.stringify({ secondary: { provider, model }, triggers: { consecutive_failures: failT, stagnation_rounds: stagT, loop_detection: loopD, risky_files_threshold: riskFiles, risky_tool_ops_threshold: riskOps, no_progress_seconds: noProgressSeconds, }, preflight: { mode: preflightMode, allow_secondary_chat: allowSecondaryChat }, limits: { assist_cooldown_rounds: cooldownRounds, max_assists_per_turn: maxAssistsTurn, max_assists_per_session: maxAssistsSession, }, browser: { max_advisor_calls_per_turn: browserCallsPerTurn, max_collected_items: browserMaxItems, max_forced_retries: browserForcedRetries, }, preempt: { enabled: preemptEnabled, stall_threshold_seconds: preemptThresholdSec, max_preempts_per_turn: preemptMaxTurn, max_preempts_per_session: preemptMaxSession, }, file_ops: { enabled: fileOpsEnabled, primary_create_max_lines: fileOpsCreateLines, primary_create_max_chars: fileOpsCreateChars, primary_edit_max_lines: fileOpsEditLines, primary_edit_max_chars: fileOpsEditChars, primary_edit_max_files: fileOpsEditFiles, verify_create_always: fileOpsVerifyCreate, verify_large_payload_lines: fileOpsVerifyLines, verify_large_payload_chars: fileOpsVerifyChars, watchdog_no_progress_cycles: fileOpsWatchdogCycles, checkpointing_enabled: fileOpsCheckpointing, }, }), }); const msg = document.getElementById('orch-eligibility-msg'); if (msg) { msg.textContent = '? Saved. Now enable the Multi-Agent Orchestrator skill in the Skills tab.'; msg.style.color = 'var(--green, #4ade80)'; msg.style.display = 'block'; } loadOrchestrationConfig(); // refresh eligibility loadInstalledSkills(); // refresh skill gating state } catch (err) { alert('Failed to save: ' + (err.message || err)); } } function syncOrchPreemptControls() { const provider = document.getElementById('settings-llm-provider')?.value || 'ollama'; const note = document.getElementById('orch-preempt-note'); const fields = [ document.getElementById('orch-preempt-enabled'), document.getElementById('orch-preempt-threshold'), document.getElementById('orch-preempt-max-turn'), document.getElementById('orch-preempt-max-session'), ].filter(Boolean); const ollamaPrimary = provider === 'ollama'; fields.forEach(el => { el.disabled = !ollamaPrimary; }); if (note) note.style.display = ollamaPrimary ? 'none' : 'block'; } // --- Skill card eligibility rendering --------------------------------------- function renderSkillEligibility(skills) { // Called after skills render — grey out ineligible skill cards setTimeout(() => { document.querySelectorAll('.skill-card').forEach(card => { const skillId = card.dataset.skillId; const skill = skills.find(s => s.id === skillId); if (!skill) return; if (skill.eligible === false) { card.style.opacity = '0.5'; // Keep card clickable when already enabled so user can still disable it. card.style.pointerEvents = skill.enabled ? 'auto' : 'none'; card.title = skill.eligibleReason || 'Not available'; const badge = document.createElement('div'); badge.textContent = skill.eligibleReason || 'Configure secondary model first'; badge.style = 'font-size:10px;color:var(--muted);margin-top:4px;'; if (!card.querySelector('.elig-badge')) { badge.className = 'elig-badge'; card.appendChild(badge); } } }); }, 50); } // --- Handle orchestration SSE events ---------------------------------------- function handleOrchestrationEvent(data) { const panel = document.getElementById('tool-calls-panel') || document.getElementById('steps-panel') || document.getElementById('current-turn-process') || document.getElementById('process-log'); if (!panel) return; const el = document.createElement('div'); el.style = 'padding:6px 10px;margin:4px 0;background:linear-gradient(135deg,#1a1a2e,#16213e);border-left:3px solid #7c3aed;border-radius:4px;font-size:12px;color:#a78bfa;'; const modeLabel = data.mode === 'planner' ? 'Planner' : 'Rescue'; const routeText = data.route ? ` | route=${data.route}` : ''; const countText = (typeof data.assist_count === 'number' && typeof data.assist_cap === 'number') ? ` | assists ${data.assist_count}/${data.assist_cap}` : ''; el.innerHTML = `Advisor (${modeLabel})
Trigger: ${data.reason || data.trigger}${routeText}${countText}`; if (data.advice?.next_actions?.length) { el.innerHTML += '
? ' + data.advice.next_actions[0] + ''; } if (data.preflight?.quick_plan?.length) { el.innerHTML += '
? ' + data.preflight.quick_plan[0] + ''; } panel.appendChild(el); panel.scrollTop = panel.scrollHeight; } // --- Expose to SSE handler --------------------------------------------------- if (typeof window !== 'undefined') { window._orchEventHandler = handleOrchestrationEvent; } // ---- Refresh all ---- async function refreshAll() { renderSessionsList(); updateStats([]); loadApprovals(); checkStatus(); loadSystemStats(); loadInstalledSkills(); loadSearchSettingsSummary(); loadAgentSessionContext(); _fetchAppCtxMax(); }