const _DBG = false; // set true to enable streaming/editor debug logs const API = window.location.origin || 'http://127.0.0.1:18789'; let ws = null; let wsWakeLock = null; let selectedJobId = null; let allJobs = []; let pollInterval = null; let currentMode = 'chat'; let sidebarTab = 'jobs'; let useAgentMode = false; const CHAT_SESSIONS_KEY_BASE = 'homeclaw_chat_sessions_v1'; const AGENT_SESSION_KEY_BASE = 'homeclaw_agent_session_id'; const THEME_KEY = 'homeclaw_theme'; const TOKEN_KEY = 'smallclaw_token'; function toggleModeDropdown(e, id) { if (e) e.stopPropagation(); var menu = document.getElementById(id.replace('mode-dropdown-', 'mode-dropdown-menu-')); if (!menu) return; var opening = !menu.classList.contains('open'); document.querySelectorAll('.mode-dropdown-menu.open').forEach(closeMenu); if (opening) { menu.classList.add('open'); positionModeDropdown(menu); setTimeout(function() { document.addEventListener('click', function closeModeDropdown() { closeMenu(menu); document.removeEventListener('click', closeModeDropdown); }); }, 0); } } function closeMenu(m) { m.classList.remove('open'); m.classList.remove('dd-floating'); m.style.left = ''; m.style.top = ''; // Put it back next to its button, so previousElementSibling still finds the button next time. if (m._ddHome) { m._ddHome.appendChild(m); m._ddHome = null; } } /** * Place an open dropdown on mobile by moving it out to . * * On mobile the app strip becomes a horizontally scrolling container with a right-edge fade * (mask-image). Both of those clip descendants, so a menu hanging below the strip was clipped out * of existence — the dropdown opened and simply could not be seen, and z-index cannot climb out of * an ancestor's clip. * * position:fixed alone does NOT fix it: fixed escapes overflow clipping but not a mask. Measured * 2026-08-14 with the menu fixed and still inside the strip — invisible, with painted at * its centre. Leaving the strip entirely is what works, so the menu is reparented to while * open and returned to its dropdown afterwards. Desktop is untouched: there the strip has no * scroller and no mask, and plain CSS-absolute placement already works. */ function positionModeDropdown(menu) { if (window.innerWidth > 1024) return; var btn = menu.previousElementSibling; if (!btn) return; var r = btn.getBoundingClientRect(); menu._ddHome = menu.parentElement; document.body.appendChild(menu); menu.classList.add('dd-floating'); var width = menu.offsetWidth || 130; // measured after the move, once it is laid out unclipped // Buttons near the right edge would otherwise push the menu off-screen. menu.style.left = Math.round(Math.max(4, Math.min(r.left, window.innerWidth - width - 4))) + 'px'; menu.style.top = Math.round(r.bottom + 4) + 'px'; } function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } } function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} } function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; } function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; } function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; } function generateSessionId() { return (crypto?.randomUUID?.() || ('sess_' + Math.random().toString(36).slice(2))); } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return ''; } function setAgentSessionId(id) { const next = String(id || '').trim() || generateSessionId(); agentSessionId = next; localStorage.setItem(agentSessionKey(), next); return next; } let agentSessionId = ''; let currentUser = null; let userLogoUrl = '/homeclaw_logo.png'; let brandName = 'HomeClaw'; setAgentSessionId(generateSessionId()); // always fresh session on page load checkAuth(); // Check authentication on page load initTTSToggle(); initMicAutosendToggle(); // ── Server log panel ────────────────────────────────────────────────────────── let logScrollLocked = true; let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size // 빈 Set = 전체 표시 / 값 있으면 해당 레벨만 표시 const logLevelFilter = new Set(); const logLevelCounts = { log: 0, warn: 0, error: 0 }; function toggleLogFilter(level) { if (logLevelFilter.has(level)) { logLevelFilter.delete(level); } else { logLevelFilter.add(level); } applyLogFilters(); } function setAllLogFilters(on) { logLevelFilter.clear(); if (!on) logLevelFilter.add('__none__'); applyLogFilters(); } function applyLogFilters() { const body = document.getElementById('sidebar-log-body'); if (body) { const none = logLevelFilter.has('__none__'); const filtered = !none && logLevelFilter.size > 0; body.classList.toggle('filter-active', filtered || none); ['log', 'warn', 'error'].forEach(lv => { body.classList.toggle('hide-' + lv, none || (filtered && !logLevelFilter.has(lv))); }); // 필터 결과 빈 경우 안내 메시지 let emptyEl = body.querySelector('.log-empty-hint'); const none2 = logLevelFilter.has('__none__'); const filtered2 = !none2 && logLevelFilter.size > 0; const visibleCount = none2 ? 0 : filtered2 ? [...logLevelFilter].reduce((s, lv) => s + (logLevelCounts[lv] || 0), 0) : logLevelCounts.log + logLevelCounts.warn + logLevelCounts.error; if (visibleCount === 0 && (none2 || filtered2)) { if (!emptyEl) { emptyEl = document.createElement('div'); emptyEl.className = 'log-empty-hint'; emptyEl.style.cssText = 'padding:12px;text-align:center;color:var(--muted);font-size:11px;'; body.appendChild(emptyEl); } emptyEl.textContent = none2 ? '필터: 없음' : `해당 레벨 메시지 없음 (${[...logLevelFilter].map(l=>l.toUpperCase()).join(', ')})`; } else if (emptyEl) { emptyEl.remove(); } } // 버튼: 활성 상태 + 카운트 document.querySelectorAll('.log-filter-btn[data-level]').forEach(btn => { const lv = btn.dataset.level; const none = logLevelFilter.has('__none__'); const filtered = !none && logLevelFilter.size > 0; btn.classList.toggle('active', !none && (!filtered || logLevelFilter.has(lv))); const cnt = logLevelCounts[lv] || 0; btn.title = `${lv.toUpperCase()}: ${cnt}건`; }); const allBtn = document.querySelector('.log-filter-all'); const noneBtn = document.querySelector('.log-filter-none'); if (allBtn) allBtn.classList.toggle('active', logLevelFilter.size === 0); if (noneBtn) noneBtn.classList.toggle('active', logLevelFilter.has('__none__')); } let logSearchQuery = ''; function onLogSearch(q) { logSearchQuery = q.trim().toLowerCase(); const clearBtn = document.getElementById('log-search-clear'); const countEl = document.getElementById('log-search-count'); if (clearBtn) clearBtn.style.display = logSearchQuery ? '' : 'none'; const body = document.getElementById('sidebar-log-body'); if (!body) return; const lines = body.querySelectorAll('.log-line'); let matchCount = 0; lines.forEach(line => { const msgEl = line.querySelector('.log-msg'); const tsEl = line.querySelector('.log-ts'); if (!msgEl) return; if (!logSearchQuery) { // 검색 없음: 원래 텍스트 복원, no-match 제거 line.classList.remove('no-match'); if (msgEl._origText !== undefined) { msgEl.textContent = msgEl._origText; delete msgEl._origText; } return; } const orig = msgEl._origText !== undefined ? msgEl._origText : msgEl.textContent; msgEl._origText = orig; const tsText = tsEl ? tsEl.textContent : ''; const haystack = (orig + ' ' + tsText).toLowerCase(); const matches = haystack.includes(logSearchQuery); line.classList.toggle('no-match', !matches); if (matches) { matchCount++; // 하이라이트: 대소문자 무시 치환 const re = new RegExp(logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); msgEl.innerHTML = orig.replace(re, m => `${m}`); } else { msgEl.textContent = orig; } }); if (countEl) { if (logSearchQuery) { countEl.textContent = `${matchCount}건`; countEl.style.display = ''; countEl.style.color = matchCount === 0 ? '#ef4444' : 'var(--muted)'; } else { countEl.style.display = 'none'; } } } function toggleLogPanel() { const panel = document.getElementById('sidebar-log-panel'); if (!panel) return; panel.classList.toggle('collapsed'); localStorage.setItem('logPanelCollapsed', panel.classList.contains('collapsed') ? '1' : '0'); } function toggleLogScrollLock() { logScrollLocked = !logScrollLocked; const btn = document.getElementById('log-scroll-lock-btn'); if (btn) btn.style.opacity = logScrollLocked ? '1' : '0.4'; } function clearLogPanel() { const body = document.getElementById('sidebar-log-body'); if (body) body.innerHTML = ''; logLastTotalCount = 0; logLevelCounts.log = 0; logLevelCounts.warn = 0; logLevelCounts.error = 0; const inp = document.getElementById('log-search-input'); if (inp) inp.value = ''; logSearchQuery = ''; applyLogFilters(); onLogSearch(''); } function renderLogEntries(entries) { const body = document.getElementById('sidebar-log-body'); if (!body) return; const frag = document.createDocumentFragment(); for (const e of entries) { const lv = e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : 'log'; logLevelCounts[lv] = (logLevelCounts[lv] || 0) + 1; const lvCls = lv === 'error' ? 'error lv-error' : lv === 'warn' ? 'warn lv-warn' : 'lv-log'; const line = document.createElement('div'); line.className = 'log-line ' + lvCls; const ts = document.createElement('span'); ts.className = 'log-ts'; ts.textContent = e.ts; const msg = document.createElement('span'); msg.className = 'log-msg'; msg.textContent = e.msg; line.appendChild(ts); line.appendChild(msg); frag.appendChild(line); } body.appendChild(frag); if (logScrollLocked) body.scrollTop = body.scrollHeight; applyLogFilters(); if (logSearchQuery) onLogSearch(logSearchQuery); } async function pollServerLogs() { try { const data = await api('/api/logs'); const entries = data?.entries || []; const totalCount = data?.totalCount ?? entries.length; if (totalCount <= logLastTotalCount) return; const newCount = totalCount - logLastTotalCount; // Slice new entries from the end of the ring (handles wrap-around correctly) const newEntries = entries.slice(Math.max(0, entries.length - newCount)); renderLogEntries(newEntries); logLastTotalCount = totalCount; } catch {} } setInterval(pollServerLogs, 2000); pollServerLogs(); // ───────────────────────────────────────────────────────────────────────────── // Chat state let chatHistory = []; let chatSessions = []; let activeChatSessionId = null; let _appCtxMax = 0; // force=true re-queries even when a value is cached — the window belongs to the // active model, so a cache-once made the gauge keep showing the previous model's // max after switching provider/model. async function _fetchAppCtxMax(force) { if (_appCtxMax > 0 && !force) return; try { const r = await fetch('/api/model-context', { headers: authHeaders() }); if (!r.ok) return; const d = await r.json(); // provisional = the server could not actually resolve the window (지서버 asleep, etc) and // handed us its 8192 last resort. Show it, but leave _appCtxMax unset so the next call // re-asks — caching a guess is what pinned the panel at 8K until a page reload. if (d.provisional) { renderSessionsList(); return; } if (d.contextWindow && d.contextWindow !== _appCtxMax) { _appCtxMax = d.contextWindow; renderSessionsList(); } } catch (_) {} } let sessionsEditMode = false; let isThinking = false; let thinkingStartedAt = 0; let thinkingTicker = null; let streamingSessionId = null; // which session owns the active stream let processLogEntries = []; let currentTurnStartIndex = -1; let lastAgentMode = '-'; let lastTurnKind = '-'; let settingsTab = 'system'; let liveTaskSteps = []; let quickSearchRigor = 'verified'; let _searchSettingsLoaded = false; // true after openSettings() successfully loads search config let _modelSettingsLoaded = false; // true after loadModelSettings() successfully populates model/provider DOM let quickThinkingEffort = localStorage.getItem('homeclaw_quick_thinking_effort') || 'standard'; let queuedPrompts = []; const MAX_QUEUED_PROMPTS = 8; let currentPreflightStatus = ''; let currentProgressLines = []; let processLogAutoFollow = true; let rightColumnAutoFollow = true; // Agent Execution Tracking let agentExecutionMap = new Map(); // Map let agentIdCounter = 0; const AGENT_STATUS = { ACTIVE: 'active', COMPLETED: 'completed', PAUSED: 'paused' }; let agentsConfigList = []; let selectedAgentId = ''; let agentMdEditor = null; // (skills cache moved to skillsCache in v2 skills management section) let lastHeartbeat = { state: 'idle', level: '', current_step: '-', retry_count: 0, format_violation_count: 0, message: '', }; let lastHeartbeatLogSignature = ''; // --- Heartbeat / Tasks System ----------------------------------------------- // ---- Sidebar tabs ---- function setSidebarTab(tab) { sidebarTab = tab; const toggle = (id, active) => document.getElementById(id)?.classList.toggle('active', active); toggle('tab-jobs', tab === 'jobs'); toggle('tab-bgtasks', tab === 'bgtasks'); toggle('tab-skills', tab === 'skills'); toggle('tab-pptx', tab === 'pptx'); toggle('tab-schedule', tab === 'schedule'); document.getElementById('sidebar-jobs').style.display = (tab === 'jobs' || tab === 'bgtasks') ? 'flex' : 'none'; document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none'; document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none'; const schedEl = document.getElementById('sidebar-schedule'); if (schedEl) schedEl.style.display = tab === 'schedule' ? 'flex' : 'none'; // 메인 뷰 전환 if (tab === 'bgtasks') { setMode('bgtasks'); } else if (currentMode === 'bgtasks') { setMode('chat'); } if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); } if (tab === 'schedule') refreshSchedules(); } // ---- Agent mode toggle ---- function updateAgentMode() { useAgentMode = document.getElementById('agent-mode-toggle').checked; document.getElementById('agent-mode-label').textContent = useAgentMode ? '에이전트' : '채팅'; document.getElementById('agent-mode-badge').classList.toggle('visible', useAgentMode); document.getElementById('chat-input').placeholder = useAgentMode ? 'Ask agent to do something... (has access to tools)' : 'Type a message... (Enter to send, Shift+Enter for newline)'; } // ---- Chat ---- // ---- WebSocket ---- // Screen Wake Lock keeps this main WS connection alive on mobile. Without it, the OS // screen-timeout (idle reading a reply, thinking before typing) turns the screen off, the // tab gets backgrounded, and the browser throttles/suspends it — the socket misses the // server's 30s pings, gets terminated after ~60s, and the client sees a "connection keeps // dropping" pattern that never shows up on a desktop/always-on connection. Same root cause // as the voice-call page's drop bug (fixed 2026-07-09 with the same pattern, see // voice-call.js) — added here 2026-08-10 after the same symptom showed up on the main chat // while used away from home wifi. A Wake Lock is auto-released whenever the tab loses // visibility, so it has to be re-requested once the tab is visible again. async function acquireWsWakeLock() { if (!('wakeLock' in navigator)) return; try { wsWakeLock = await navigator.wakeLock.request('screen'); wsWakeLock.addEventListener('release', () => { wsWakeLock = null; }); } catch (e) { console.warn('[app] wsWakeLock request failed:', e.message); } } document.addEventListener('visibilitychange', () => { if (ws && ws.readyState === WebSocket.OPEN && wsWakeLock === null && document.visibilityState === 'visible') { acquireWsWakeLock(); } }); function connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const token = getAuthToken(); const qs = token ? `?token=${encodeURIComponent(token)}` : ''; ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws${qs}`); ws.onopen = () => { acquireWsWakeLock(); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'jobs_update') { allJobs = msg.jobs; updateStats([]); if (selectedJobId) refreshJobDetail(selectedJobId); } if (msg.type === 'job_created') { log(`Job created: ${msg.jobId}`, 'info'); } if (msg.type === 'boot_greeting') { // Show boot greeting in current session regardless of existing history const bootSess = chatSessions.find(s => s.id === activeChatSessionId); if (bootSess) { if (!bootSess.history) bootSess.history = []; bootSess.history.push({ role: 'assistant', content: msg.text }); bootSess.updatedAt = Date.now(); saveChatSessions(); syncActiveChat(); renderChatMessages(); } } // -- Cron/Heartbeat task events -- if (msg.type === 'tasks_update') { hbJobs = msg.jobs || []; if (msg.config) hbConfig = msg.config; if (currentMode === 'heartbeat') renderHeartbeatPage(); } if (msg.type === 'task_running') { const job = hbJobs.find(j => j.id === msg.jobId); if (job) job.status = 'running'; if (currentMode === 'heartbeat') renderHeartbeatPage(); addProcessEntry('info', `Task running: ${msg.jobName}`); } if (msg.type === 'task_done') { hbJobs = msg.jobs || hbJobs; if (msg.config) hbConfig = msg.config; if (currentMode === 'heartbeat') renderHeartbeatPage(); if (msg.automatedSession && !msg.isOk) { // Add the automated session to chatSessions so it appears in the sidebar const as = msg.automatedSession; const existingIdx = chatSessions.findIndex(s => s.id === as.id); const newSession = { id: as.id, title: as.title, history: as.history.map(m => ({ role: m.role === 'ai' ? 'assistant' : m.role, content: m.content })), processLog: [], createdAt: as.createdAt, updatedAt: Date.now(), automated: true, jobName: as.jobName, unread: true, }; if (existingIdx === -1) chatSessions.push(newSession); else chatSessions[existingIdx] = { ...chatSessions[existingIdx], ...newSession }; saveChatSessions(); renderSessionsList(); bgtToast('\uD83D\uDD50 Scheduled task done', `"${msg.jobName}" — results in sidebar`); addProcessEntry('info', `Task "${msg.jobName}" completed — check sidebar for results`); } else if (msg.isOk) { addProcessEntry('info', `Task "${msg.jobName}" — OK (nothing to report)`); } } // Cron job spawned a background task — switch to tasks view if currently watching if (msg.type === 'cron_task_spawned') { bgtToast('\uD83D\uDD50 Scheduled job running', `"${msg.jobName}" started as background task`); if (currentMode === 'bgtasks') refreshBgTasks(); } if (msg.type === 'task_sse') { // Live progress from a running task — could update the NOW card if (currentMode === 'heartbeat' && msg.event === 'tool_result') { const card = document.querySelector(`[data-job-id="${msg.jobId}"] .hb-card-meta`); if (card) card.innerHTML = `Working: ${escHtml((msg.data?.action || '').slice(0,40))}...`; } } // -- Background Task (Kanban) events -- if (msg.type === 'task_running' || msg.type === 'task_complete' || msg.type === 'task_paused' || msg.type === 'task_failed' || msg.type === 'task_needs_assistance' || msg.type === 'task_step_done' || msg.type === 'task_tool_call' || msg.type === 'task_heartbeat_tick' || msg.type === 'task_heartbeat_resumed') { // Live-refresh the kanban board if visible if (currentMode === 'bgtasks') refreshBgTasks(); // If panel is open for this task, refresh it if (bgtOpenTaskId && (msg.taskId === bgtOpenTaskId)) { bgtRefreshOpenPanel(); } // Toast notification for completions and errors if (msg.type === 'task_complete') { bgtToast(`\u2713 Task complete`, msg.summary ? msg.summary.slice(0,120) : ''); // If this task has a cron session ID (starts with cron_), create/update a sidebar session // so the results are immediately visible without a page refresh. if (msg.taskId) { (async () => { try { const data = await fetch(`/api/bg-tasks/${msg.taskId}`, { headers: authHeaders() }).then(r => r.json()); const task = data?.task || data; if (task && task.sessionId && task.sessionId.startsWith('cron_')) { const journal = task.journal || []; const summaryEntry = journal.filter(e => e.type === 'summary' || e.type === 'complete').pop(); const summaryText = msg.summary || (summaryEntry?.content) || '(Task complete — open Tasks panel for details)'; const titleText = `\uD83D\uDD50 ${task.title || 'Scheduled Task'} \u2014 ${new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`; const sessionId = `auto_${task.id}_${Date.now()}`; const existingIdx = chatSessions.findIndex(s => s.id === task.sessionId || (s.jobName && s.automated && s.title === titleText)); const newSession = { id: sessionId, title: titleText, history: [ { role: 'user', content: `[Scheduled Task: ${task.title}]\n\n${task.prompt || ''}` }, { role: 'assistant', content: summaryText }, ], processLog: [], createdAt: Date.now(), updatedAt: Date.now(), automated: true, jobName: task.title, unread: true, }; if (existingIdx === -1) chatSessions.push(newSession); saveChatSessions(); renderSessionsList(); } } catch {} })(); } } else if (msg.type === 'task_failed') { bgtToast(`\u2715 Task failed`, msg.error ? msg.error.slice(0,120) : ''); } else if (msg.type === 'task_needs_assistance') { bgtToast(`Task paused`, msg.reason ? String(msg.reason).slice(0, 120) : 'Needs assistance'); } } // Panel-only refresh (frequent tool_call updates — only refresh open panel, not full kanban) if (msg.type === 'task_panel_update') { if (bgtOpenTaskId && msg.taskId === bgtOpenTaskId) { bgtRefreshOpenPanel(); } } // Notification from task runner (status push to channel) if (msg.type === 'task_notification' && msg.sessionId) { // Try to add a status message to the originating chat session const sess = chatSessions.find(s => s.id === msg.sessionId); if (sess) { sess.history = sess.history || []; sess.history.push({ role: 'assistant', content: msg.message || '' }); saveChatSessions(); if (currentSessionId === msg.sessionId) renderMessages(); } } // ---- Agent Execution Events ---- if (msg.type === 'agent_spawned') { const agentId = spawnAgentExecution(msg.name || 'Subagent', msg.task || '', msg.isSubagent); // Store mapping for WebSocket message routing if (msg.serverAgentId) { if (!window.agentIdMap) window.agentIdMap = {}; window.agentIdMap[msg.serverAgentId] = agentId; } } if (msg.type === 'agent_log') { const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId; if (agentId) addAgentLog(agentId, msg.logType || 'info', msg.content); } if (msg.type === 'agent_completed') { const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId; if (agentId) completeAgentExecution(agentId); } if (msg.type === 'agent_paused') { const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId; if (agentId) pauseAgentExecution(agentId, msg.reason); } }; ws.onclose = () => { try { wsWakeLock && wsWakeLock.release(); } catch {} wsWakeLock = null; setTimeout(connectWS, 2000); }; ws.onerror = () => ws.close(); } // ---- API ---- async function api(path, opts = {}) { const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts; const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders }); const r = await fetch(API + path, { headers, ...restOpts }); if (r.status === 401) { setAuthToken(''); window.location.href='/html/login.html'; throw new Error('Authentication required'); } const ct = String(r.headers.get('content-type') || '').toLowerCase(); const bodyText = await r.text(); if (!ct.includes('application/json')) { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`); } let data = null; try { data = bodyText ? JSON.parse(bodyText) : null; } catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); } if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); } return data; } // --- Per-User Telegram Bot --------------------------------------------------- function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } // --- Context Viewer Tab ------------------------------------------------------- let cvAutoRefreshTimer = null; async function loadContextViewerTab() { const listEl = document.getElementById('cv-sessions-list'); const warnEl = document.getElementById('cv-warn-banner'); if (!listEl) return; listEl.innerHTML = '
로딩 중...
'; if (warnEl) warnEl.style.display = 'none'; try { const data = await api('/api/admin/context-viewer'); if (!data || !data.sessions) { listEl.innerHTML = '
권한이 없거나 데이터를 불러올 수 없습니다.
'; return; } const sessions = data.sessions; if (sessions.length === 0) { listEl.innerHTML = '
세션이 없습니다.
'; return; } // Token warning banner const warnSessions = sessions.filter(s => s.usagePct >= 70); if (warnEl && warnSessions.length > 0) { warnEl.style.display = 'block'; warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => { const color = s.usagePct >= 90 ? '#dc2626' : '#d97706'; return `${s.username} (${s.id.slice(0,8)}…) ${s.usagePct}%`; }).join('  |  '); } listEl.innerHTML = sessions.map(s => { const pct = s.usagePct; const barColor = pct >= 90 ? 'var(--err)' : pct >= 70 ? 'var(--warn)' : 'var(--ok)'; const lastActive = s.lastActiveAt ? new Date(s.lastActiveAt).toLocaleString() : '-'; const onlineDot = s.online ? '' : ''; return `
${onlineDot} ${escHtml(s.username)} ${s.id.slice(0,12)}…
${lastActive} ${pct >= 70 ? `${pct}%` : ''}
${s.contextTokenEstimate.toLocaleString()} / ${s.numCtx.toLocaleString()} tok
메시지 ${s.historyCount}개
${s.lastUserMessage ? `
사용자: ${escHtml(s.lastUserMessage)}
` : ''} ${s.lastAssistantMessage ? `
AI: ${escHtml(s.lastAssistantMessage)}
` : ''}
`; }).join(''); } catch (e) { listEl.innerHTML = `
오류: ${e.message || '알 수 없는 오류'}
`; } } async function loadBannedIpsTab() { const listEl = document.getElementById('cv-banned-ips-list'); const statEl = document.getElementById('cv-banned-ips-stat'); if (!listEl) return; listEl.innerHTML = '
로딩 중...
'; if (statEl) statEl.textContent = ''; try { const data = await api('/api/admin/banned-ips'); if (!data || !data.banned) { listEl.innerHTML = '
권한이 없거나 데이터를 불러올 수 없습니다.
'; return; } const banned = data.banned; if (statEl) statEl.textContent = `현재 차단 ${banned.length}건 · 누적 밴 ${data.totalBans.toLocaleString()}건`; if (banned.length === 0) { listEl.innerHTML = '
현재 차단된 IP가 없습니다.
'; return; } listEl.innerHTML = banned.map(b => `
${escHtml(b.ip)} ${escHtml(b.jail)}
${escHtml(b.bannedAt)}
`).join(''); } catch (e) { listEl.innerHTML = `
오류: ${e.message || '알 수 없는 오류'}
`; } } async function cvDeleteSession(username, sessionId) { try { const res = await fetch(`/api/admin/sessions/${encodeURIComponent(username)}/${encodeURIComponent(sessionId)}`, { method: 'DELETE', headers: authHeaders(), }); if (!res.ok) throw new Error((await res.json()).error || res.statusText); const card = document.getElementById('cv-card-' + sessionId); if (card) card.remove(); } catch (e) { alert('삭제 실패: ' + (e.message || e)); } } // --- PPT Template & Skin Tab --------------------------------------------------- // --- Channels Settings Functions --------------------------------------- // ─── Email Account Management (Channel Settings) ───────────────────────── // ─── Heartbeat Notify Toggle ────────────────────────────────────────────── async function saveSettings() { const workspace_path = document.getElementById('settings-workspace-path').value.trim(); const allowed_paths = document.getElementById('settings-allowed-paths').value.split('\n').map(s => s.trim()).filter(Boolean); const blocked_paths = document.getElementById('settings-blocked-paths').value.split('\n').map(s => s.trim()).filter(Boolean); // If search settings were never loaded from the server (API failed or race with async load), // fetch current values now so we don't overwrite searxng_url / preferred_provider with HTML defaults. if (!_searchSettingsLoaded) { try { const s = await api('/api/settings/search'); document.getElementById('settings-provider').value = s.preferred_provider || ''; document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified'; if (document.getElementById('settings-search-searxng-url')) document.getElementById('settings-search-searxng-url').value = s.searxng_url || ''; if (document.getElementById('settings-search-ollama-model')) document.getElementById('settings-search-ollama-model').value = s.ollama_model || ''; _searchSettingsLoaded = true; } catch {} } const payload = { preferred_provider: document.getElementById('settings-provider').value, search_rigor: document.getElementById('settings-search-rigor').value, tavily_api_key: document.getElementById('cred-tavily-key')?.value.trim() || '', google_api_key: document.getElementById('cred-google-key')?.value.trim() || '', google_cx: document.getElementById('cred-google-cx')?.value.trim() || '', brave_api_key: document.getElementById('cred-brave-key')?.value.trim() || '', ollama_model: document.getElementById('settings-search-ollama-model')?.value.trim() || '', searxng_url: document.getElementById('settings-search-searxng-url')?.value.trim() || '', }; const policyPayload = { force_web_for_fresh: document.getElementById('settings-force-web-fresh').checked, memory_fallback_on_search_failure: document.getElementById('settings-memory-fallback').checked, auto_store_web_facts: document.getElementById('settings-auto-store-web-facts').checked, natural_language_tool_router: document.getElementById('settings-nl-tool-router').checked, retrieval_mode: document.getElementById('settings-retrieval-mode').value, }; // If model/provider settings were never loaded (models tab not opened), fetch and populate DOM now // so buildProviderPayload() doesn't send HTML defaults (provider='ollama', empty model, etc.) if (!_modelSettingsLoaded) { try { const data = await api('/api/settings/provider'); const llm = data?.llm || {}; const prov = llm.provider || ''; const pc = llm.providers || {}; const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; }; if (prov) { const el = document.getElementById('settings-llm-provider'); if (el) el.value = prov; } v('settings-ollama-endpoint', pc.ollama?.endpoint); v('settings-ollamalocal-endpoint', pc.ollama_local?.endpoint); // 지서버's model field is a