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 = '