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 selectedJobId = null; let allJobs = []; let pollInterval = null; let currentMode = 'chat'; let sidebarTab = 'jobs'; let useAgentMode = false; const CHAT_SESSIONS_KEY_BASE = 'cherryclaw_chat_sessions_v1'; const AGENT_SESSION_KEY_BASE = 'cherryclaw_agent_session_id'; const THEME_KEY = 'cherryclaw_theme'; const TOKEN_KEY = 'smallclaw_token'; function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } } function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.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; 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__')); } 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; applyLogFilters(); } 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(); } 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 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('cherryclaw_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 = ''; function renderQueuedPromptsPanel() { const panel = document.getElementById('queued-prompts-panel'); const list = document.getElementById('queued-prompts-list'); const title = document.getElementById('queued-prompts-title'); if (!panel || !list || !title) return; const count = queuedPrompts.length; if (count === 0) { panel.style.display = 'none'; list.innerHTML = ''; return; } panel.style.display = 'block'; title.textContent = `Queued prompts (${count})`; list.innerHTML = queuedPrompts.map((q, i) => `
${escHtml(q)}
`).join(''); } function removeQueuedPrompt(index) { if (!Number.isInteger(index) || index < 0 || index >= queuedPrompts.length) return; queuedPrompts.splice(index, 1); updateQueuedPromptUI(); } function clearQueuedPrompts() { if (!queuedPrompts.length) return; queuedPrompts = []; addProcessEntry('info', 'Cleared queued prompts.'); updateQueuedPromptUI(); } function updateQueuedPromptUI() { const badge = document.getElementById('queued-prompts-indicator'); const sendBtn = document.getElementById('send-btn'); const input = document.getElementById('chat-input'); const count = queuedPrompts.length; if (badge) { if (count > 0) { badge.style.display = 'inline'; badge.textContent = `Queued: ${count}`; } else { badge.style.display = 'none'; badge.textContent = ''; } } if (sendBtn) { sendBtn.disabled = false; sendBtn.title = isThinking ? `Queue next prompt${count ? ` (${count} queued)` : ''}` : 'Send'; } if (input) { input.placeholder = isThinking ? 'Assistant is responding. Press Enter/Send to queue next prompt.' : 'Type a message... (Enter to send, Shift+Enter for newline)'; } renderQueuedPromptsPanel(); } function saveChatSessions() { localStorage.setItem(chatSessionsKey(), JSON.stringify(chatSessions)); } function makeSessionTitle(history) { const firstUser = (history || []).find(m => m.role === 'user'); if (!firstUser) return 'New chat'; // Strip markdown image/link syntax for clean session title let text = firstUser.content .replace(/!\[[^\]]*\]\([^)]+\)/g, '[image]') .replace(/\[[^\]]+\]\([^)]+\)/g, (m) => m.replace(/\[([^\]]*)\].*/, '$1')) .replace(/```[\s\S]*?```/g, '') .replace(/\n/g, ' ') .trim(); return text.slice(0, 42) || 'New chat'; } function loadChatSessions() { const key = chatSessionsKey(); try { const raw = localStorage.getItem(key); if (raw) { chatSessions = JSON.parse(raw); } else { // Migrate from old non-namespaced key once, then delete it so other users can't see it. const oldRaw = localStorage.getItem(CHAT_SESSIONS_KEY_BASE); if (oldRaw) { chatSessions = JSON.parse(oldRaw); localStorage.setItem(key, oldRaw); localStorage.removeItem(CHAT_SESSIONS_KEY_BASE); } else { chatSessions = []; } } } catch { chatSessions = []; } if (!Array.isArray(chatSessions)) chatSessions = []; if (chatSessions.length === 0) { const id = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2)); chatSessions.push({ id, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() }); activeChatSessionId = id; setAgentSessionId(id); saveChatSessions(); } else if (!activeChatSessionId || !chatSessions.some(s => s.id === activeChatSessionId)) { activeChatSessionId = chatSessions[0].id; setAgentSessionId(activeChatSessionId); } if (!agentSessionId || agentSessionId !== activeChatSessionId) setAgentSessionId(activeChatSessionId); syncActiveChat(); // 서버 세션 목록과 비교해 누락된 세션을 사이드바에 추가 syncServerSessions(); } async function syncServerSessions() { try { const res = await fetch('/api/chat/sessions'); if (!res.ok) return; const data = await res.json(); if (!data.success || !Array.isArray(data.sessions)) return; let changed = false; for (const srv of data.sessions) { const existing = chatSessions.find(s => s.id === srv.id); if (!existing) { // 서버에만 있는 세션 → 스텁으로 추가 (history는 열 때 로드) chatSessions.push({ id: srv.id, title: srv.title || 'New chat', history: [], processLog: [], createdAt: srv.createdAt, updatedAt: srv.lastActiveAt, serverOnly: true, // 열 때 서버에서 history 로드 }); changed = true; } else if (existing.serverOnly) { // 이미 스텁으로 있으면 메타만 업데이트 existing.title = srv.title || existing.title; existing.updatedAt = srv.lastActiveAt; changed = true; } } if (changed) { saveChatSessions(); renderSessionsList(); } } catch { /* 서버 미응답 시 무시 */ } } function syncActiveChat() { const sess = chatSessions.find(s => s.id === activeChatSessionId); chatHistory = sess ? (sess.history || []) : []; processLogEntries = sess ? (sess.processLog || []) : []; liveTaskSteps = []; // clear live steps so a different session never inherits in-progress tool display // Mark session as read when opening it if (sess) sess.unread = false; renderChatMessages(); renderProcessLog(); renderLiveStepsPanel(); renderSessionsList(); updateStats([]); loadAgentSessionContext(); } function persistActiveChat() { const idx = chatSessions.findIndex(s => s.id === activeChatSessionId); if (idx === -1) return; chatSessions[idx].history = chatHistory; chatSessions[idx].processLog = processLogEntries; chatSessions[idx].title = makeSessionTitle(chatHistory); chatSessions[idx].updatedAt = Date.now(); saveChatSessions(); renderSessionsList(); updateStats([]); } function newChatSession() { const id = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2)); chatSessions.unshift({ id, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() }); activeChatSessionId = id; setAgentSessionId(id); syncActiveChat(); } function toggleSessionsEditMode() { sessionsEditMode = !sessionsEditMode; const btn = document.getElementById('sessions-edit-btn'); if (btn) btn.textContent = sessionsEditMode ? '완료' : '편집'; renderSessionsList(); } function deleteChatSession(id, ev) { if (ev) ev.stopPropagation(); const idx = chatSessions.findIndex(s => s.id === id); if (idx === -1) return; chatSessions.splice(idx, 1); if (chatSessions.length === 0) { const newId = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2)); chatSessions.push({ id: newId, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() }); activeChatSessionId = newId; setAgentSessionId(newId); } else if (activeChatSessionId === id) { activeChatSessionId = chatSessions[0].id; setAgentSessionId(activeChatSessionId); } saveChatSessions(); syncActiveChat(); // 서버 세션 파일도 삭제 (재로드 시 재등장 방지) api(`/api/chat/sessions/${id}`, { method: 'DELETE' }).catch(() => {}); } function openSession(id) { activeChatSessionId = id; setAgentSessionId(id); const sess = chatSessions.find(s => s.id === id); if (sess && sess.serverOnly && sess.history.length === 0) { // 서버에서 history 로드 후 렌더 fetch(`/api/chat/sessions/${id}`) .then(r => r.json()) .then(data => { if (data.success && Array.isArray(data.history)) { sess.history = data.history; sess.title = makeSessionTitle(data.history) || sess.title; delete sess.serverOnly; saveChatSessions(); syncActiveChat(); renderSessionsList(); } }) .catch(() => syncActiveChat()); renderSessionsList(); // 즉시 세션 활성화 표시 return; } syncActiveChat(); } function markSessionUnread(sessionId) { const sess = chatSessions.find(s => s.id === sessionId); if (sess && sessionId !== activeChatSessionId) { sess.unread = true; renderSessionsList(); } } // ---- Mode switching ---- function setMode(mode) { if (!['chat','bgtasks','schedule'].includes(mode)) mode = 'chat'; currentMode = mode; document.getElementById('btn-chat').classList.toggle('active', mode === 'chat'); const bgtBtn = document.getElementById('btn-bgtasks'); if (bgtBtn) bgtBtn.classList.toggle('active', mode === 'bgtasks'); const schedBtn = document.getElementById('btn-schedule'); if (schedBtn) schedBtn.classList.toggle('active', mode === 'schedule'); document.getElementById('chat-view').style.display = mode === 'chat' ? 'flex' : 'none'; const bgtView = document.getElementById('bgtasks-view'); if (bgtView) bgtView.style.display = mode === 'bgtasks' ? 'flex' : 'none'; const schedView = document.getElementById('schedule-view'); if (schedView) schedView.style.display = mode === 'schedule' ? 'flex' : 'none'; const aside = document.querySelector('aside'); const rightPanel = document.getElementById('right-panel'); if (aside) aside.style.display = ''; if (rightPanel) rightPanel.style.display = mode === 'chat' ? '' : 'none'; if (mode === 'bgtasks') refreshBgTasks(); if (mode === 'schedule') refreshSchedules(); } // --- Heartbeat / Tasks System ----------------------------------------------- let hbJobs = []; let hbConfig = { enabled: false, intervalMinutes: 30, activeHoursStart: 8, activeHoursEnd: 22 }; let editingTaskId = null; async function loadTasksFromServer() { try { const data = await api('/api/tasks'); if (data.success) { hbJobs = data.jobs || []; hbConfig = data.config || hbConfig; renderHeartbeatPage(); } } catch (err) { console.error('[HB] Load failed:', err); } } function renderHeartbeatPage() { // Update topbar const dot = document.getElementById('hb-dot'); const label = document.getElementById('hb-label'); const meta = document.getElementById('hb-meta'); const btn = document.getElementById('hb-toggle-btn'); if (dot) dot.className = 'hb-status-dot ' + (hbConfig.enabled ? 'active' : ''); if (label) label.textContent = hbConfig.enabled ? '하트비트 ON' : '하트비트 OFF'; if (meta) meta.textContent = `Every ${hbConfig.intervalMinutes}m \u00b7 ${hbConfig.activeHoursStart}:00\u2013${hbConfig.activeHoursEnd}:00`; if (btn) { btn.textContent = hbConfig.enabled ? 'Pause' : 'Enable'; btn.className = 'hb-btn' + (hbConfig.enabled ? ' danger' : ' primary'); } // Split jobs into sections const running = hbJobs.filter(j => j.status === 'running'); const queued = hbJobs.filter(j => j.enabled && j.status !== 'running' && j.status !== 'completed' && j.status !== 'paused').sort((a,b) => a.priority - b.priority); const paused = hbJobs.filter(j => j.status === 'paused'); const completed = hbJobs.filter(j => j.status === 'completed' || j.type === 'one-shot' && j.lastRun).slice(0, 15); const empty = document.getElementById('hb-empty'); if (empty) empty.style.display = hbJobs.length === 0 ? '' : 'none'; renderHBSection('hb-section-running', 'hb-running-cards', running, 'running'); renderHBSection('hb-section-queued', 'hb-queued-cards', [...queued, ...paused], 'queued'); renderHBSection('hb-section-scheduled', 'hb-scheduled-cards', queued.filter(j => j.schedule), 'scheduled'); renderHBSection('hb-section-completed', 'hb-completed-cards', completed, 'completed'); } function renderHBSection(sectionId, cardsId, jobs, sectionType) { const section = document.getElementById(sectionId); const container = document.getElementById(cardsId); if (!section || !container) return; section.style.display = jobs.length > 0 ? '' : 'none'; container.innerHTML = jobs.map(j => renderTaskCard(j, sectionType)).join(''); } function renderTaskCard(job, sectionType) { const statusClass = job.status === 'running' ? 'running' : job.status === 'completed' ? 'completed' : job.status === 'paused' ? 'paused' : ''; const scheduleText = job.schedule ? cronToHuman(job.schedule) : (job.runAt ? 'One-shot: ' + new Date(job.runAt).toLocaleString() : 'Manual'); const lastRunText = job.lastRun ? timeAgo(new Date(job.lastRun).getTime()) : 'Never'; const nextRunText = job.nextRun ? new Date(job.nextRun).toLocaleTimeString('en-US', {hour:'2-digit',minute:'2-digit'}) : '-'; const resultSnippet = job.lastResult ? (job.lastResult.length > 60 ? job.lastResult.slice(0,60)+'...' : job.lastResult) : ''; const isOk = /^\s*HEARTBEAT_OK\s*$/i.test(job.lastResult || ''); return `
${sectionType === 'completed' ? '' : '\u2261'}
${escHtml(job.name)}
${scheduleText} Next: ${nextRunText} Last: ${lastRunText} ${resultSnippet && !isOk ? `${escHtml(resultSnippet)}` : ''} ${isOk ? '\u2713 OK' : ''}
${job.status !== 'running' ? `` : 'Running...'} ${job.sessionId ? `` : ''}
`; } function cronToHuman(expr) { if (!expr) return 'Manual'; const p = expr.split(/\s+/); if (p.length !== 5) return expr; const [min, hr, dom, , dow] = p; if (min.startsWith('*/')) return `Every ${min.slice(2)}m`; if (hr === '*') return `Every minute`; if (dom === '*' && dow === '*') return `Daily at ${hr}:${min.padStart(2,'0')}`; const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; if (dow !== '*') return `${days[Number(dow)] || dow} at ${hr}:${min.padStart(2,'0')}`; return expr; } async function toggleHeartbeatEnabled() { try { const data = await api('/api/tasks/config', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ enabled: !hbConfig.enabled }) }); if (data.success) { hbConfig = data.config; renderHeartbeatPage(); } } catch (err) { console.error('[HB] Toggle failed:', err); } } async function runTaskNow(id) { try { await api(`/api/tasks/${id}/run`, { method: 'POST' }); } catch {} } async function deleteTask(id) { if (!confirm('Delete this task?')) return; try { await api(`/api/tasks/${id}`, { method: 'DELETE' }); hbJobs = hbJobs.filter(j => j.id !== id); renderHeartbeatPage(); } catch {} } async function toggleTaskPause(id) { const job = hbJobs.find(j => j.id === id); if (!job) return; const newStatus = job.status === 'paused' ? 'scheduled' : 'paused'; try { const data = await api(`/api/tasks/${id}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ status: newStatus, enabled: newStatus !== 'paused' }) }); if (data.success) { Object.assign(job, data.job); renderHeartbeatPage(); } } catch {} } function viewTaskSession(sessionId) { // Switch to chat mode and open the automated session const session = chatSessions.find(s => s.id === sessionId); if (session) { setMode('chat'); openSession(sessionId); } } function openTaskModal(taskId) { editingTaskId = taskId || null; const job = taskId ? hbJobs.find(j => j.id === taskId) : null; const modal = document.getElementById('task-modal'); modal.style.display = ''; modal.innerHTML = `

${job ? 'Edit Task' : 'Add Task'}

Examples: */30 * * * * (every 30m), 0 9 * * * (daily 9am), 0 9 * * 1 (Mon 9am)
`; } function editTask(id) { openTaskModal(id); } function closeTaskModal() { document.getElementById('task-modal').style.display = 'none'; editingTaskId = null; } async function saveTask() { const name = document.getElementById('tm-name').value.trim(); const prompt = document.getElementById('tm-prompt').value.trim(); const type = document.getElementById('tm-type').value; const schedule = document.getElementById('tm-schedule').value.trim(); if (!name || !prompt) { alert('Name and prompt are required'); return; } try { if (editingTaskId) { const data = await api(`/api/tasks/${editingTaskId}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) }); if (data.success) { const idx = hbJobs.findIndex(j => j.id === editingTaskId); if (idx >= 0) hbJobs[idx] = data.job; } } else { const data = await api('/api/tasks', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) }); if (data.success) hbJobs.push(data.job); } closeTaskModal(); renderHeartbeatPage(); } catch (err) { alert('Failed to save: ' + err.message); } } // ---- Sidebar tabs ---- function setSidebarTab(tab) { sidebarTab = tab; document.getElementById('tab-jobs').classList.toggle('active', tab === 'jobs'); document.getElementById('tab-skills').classList.toggle('active', tab === 'skills'); document.getElementById('tab-pptx').classList.toggle('active', tab === 'pptx'); document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none'; document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none'; document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none'; if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); } } // ---- 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 ---- function renderThinkBlock(thinking) { if (!thinking || !thinking.trim()) return ''; const id = 'think_' + Math.random().toString(36).slice(2); const wordCount = thinking.trim().split(/\s+/).length; return `
${escHtml(thinking)}
`; } function toggleThink(id) { const btn = document.getElementById(id + '_btn'); const body = document.getElementById(id + '_body'); if (!btn || !body) return; const open = body.style.display !== 'none' && body.style.display !== ''; body.style.display = open ? 'none' : 'block'; btn.classList.toggle('open', !open); } function renderReactSteps(steps) { if (!steps || steps.length === 0) return ''; const toolSteps = steps.filter(s => s.action); if (toolSteps.length === 0) return ''; const getSearchDiagnostics = (s) => s?.toolData?.search_diagnostics || s?.diagnostics || null; const extractUrls = (s, resultText) => { const fromToolData = Array.isArray(s?.toolData?.results) ? s.toolData.results.map(r => String(r?.url || '').trim()).filter(u => /^https?:\/\//i.test(u)) : []; const fromText = String(resultText || '').match(/https?:\/\/[^\s)]+/g) || []; const merged = [...fromToolData, ...fromText.map(u => String(u).trim())]; const out = []; const seen = new Set(); for (const u of merged) { if (!u || seen.has(u)) continue; seen.add(u); out.push(u); if (out.length >= 5) break; } return out; }; const providerSummary = (diag) => { if (!diag || !Array.isArray(diag.attempted) || !diag.attempted.length) return ''; return diag.attempted.map(a => { const p = String(a.provider || '').toLowerCase(); const status = String(a.status || '').toLowerCase(); if (status === 'success') { const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : ''; return `${p}=success${count}`; } if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`; return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`; }).join(' | '); }; const id = 'steps_' + Math.random().toString(36).slice(2); const items = toolSteps.map(s => { const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || ''); const diag = String(s.action || '') === 'web_search' ? getSearchDiagnostics(s) : null; const query = String(diag?.query || s?.params?.query || '').trim(); const selectedProvider = String(diag?.selected_provider || s?.toolData?.provider || '').toLowerCase(); const providers = diag ? providerSummary(diag) : ''; const urls = String(s.action || '') === 'web_search' ? extractUrls(s, resultText) : []; return `
${s.thought ? `
${escHtml(s.thought.slice(0, 120))}
` : ''}
${escHtml(s.action)}
${resultText ? `
${escHtml(resultText.slice(0, 160))}${resultText.length > 160 ? '…' : ''}
` : ''} ${diag ? `
${query ? `
query: ${escHtml(query)}
` : ''} ${selectedProvider ? `
provider hit: ${escHtml(selectedProvider)}
` : ''} ${providers ? `
providers: ${escHtml(providers)}
` : ''}
` : ''} ${String(s.action || '') === 'web_search' && !diag ? `
${selectedProvider ? `
provider hit: ${escHtml(selectedProvider)}
` : ''}
` : ''} ${urls.length ? `
sources:
${urls.slice(0, 3).map((u, i) => `
${i + 1}. ${escHtml(u)}
`).join('')}
` : ''}
`; }).join(''); return `
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
`; } function toggleSteps(id) { const el = document.getElementById(id); if (!el) return; const visible = el.style.display !== 'none'; el.style.display = visible ? 'none' : 'block'; el.previousElementSibling.textContent = `${visible ? '?' : '?'} ${el.querySelectorAll('.react-step').length} tool step${el.querySelectorAll('.react-step').length !== 1 ? 's' : ''} used`; } function renderFileDownloads(steps, processEntries) { const seenUrls = new Set(); // normalized (decoded) URLs const buttons = []; const URL_RE = /\/api\/files\/[^\s)"'\]]+/g; function normalizeUrl(u) { try { return decodeURIComponent(u); } catch { return u; } } function extractUrls(text) { const urls = []; let m; while ((m = URL_RE.exec(text)) !== null) urls.push(m[0]); return urls; } // Pre-mark URLs already present in msg.content so tool-result duplicates are suppressed. // renderMarkdown() handles content links — we must not render a second button for the same URL. // Normalize both sides (decode percent-encoding) before comparing so Korean paths match. if (arguments.length > 2 && arguments[2]) { const contentText = String(arguments[2]); if (contentText.indexOf('/api/files/') !== -1) { for (const u of extractUrls(contentText)) seenUrls.add(normalizeUrl(u)); URL_RE.lastIndex = 0; } } // Collect /api/files/ URLs from tool results only (not content — already handled above) const allUrls = []; // 1. msg.steps (legacy step events) if (steps && steps.length) { for (const s of steps) { const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || ''); allUrls.push(...extractUrls(resultText)); } } // 2. msg.processEntries (stored per-message in chatHistory) if (processEntries && processEntries.length) { for (const e of processEntries) { const txt = String(e.content || ''); if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt)); } } const _imgExts = /\.(jpg|jpeg|png|gif|webp|heic|heif|bmp|svg|avif|tiff|tif)(\?|$)/i; for (const rawUrl of allUrls) { const normUrl = normalizeUrl(rawUrl); if (seenUrls.has(normUrl)) continue; seenUrls.add(normUrl); const decoded = (() => { try { return decodeURIComponent(rawUrl); } catch { return rawUrl; } })(); if (_imgExts.test(decoded)) continue; // images shown inline, no download button needed const filename = decoded.split('/').pop() || 'download'; const isPptx = decoded.toLowerCase().indexOf('.pptx') !== -1; if (isPptx) { const previewId = 'pptx-chat-' + Math.random().toString(36).substr(2, 9); buttons.push(`
⬇ ${escHtml(filename)}
`); } else { buttons.push(` ⬇ ${escHtml(filename)}`); } } return buttons.length ? `
${buttons.join(' ')}
` : ''; } function renderAssistantContent(content) { const text = String(content || ''); const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text); if (!hasStaged) return `
${renderMarkdown(text)}
`; const chunks = text .split(/\n\s*---\s*\n/g) .map(s => String(s || '').trim()) .filter(Boolean); const html = chunks.map((chunk, idx) => { const m = chunk.match(/^(Initial chat|Execution result|Final chat):\s*\n?/i); const title = m ? m[1] : `Section ${idx + 1}`; const body = m ? chunk.slice(m[0].length).trim() : chunk; return `
${escHtml(title)}
${renderMarkdown(body)}
`; }).join(''); return `
${html}
`; } function renderArtifacts(artifacts) { const rows = Array.isArray(artifacts) ? artifacts : []; if (!rows.length) return ''; const cards = rows.map((a, idx) => { const type = String(a?.type || '').trim(); const title = String(a?.title || type || `Artifact ${idx + 1}`).trim(); const status = String(a?.status || 'ok').toLowerCase(); const pathVal = String(a?.path || '').trim(); const fromPath = String(a?.from_path || '').trim(); const toPath = String(a?.to_path || '').trim(); const summary = String(a?.summary || '').trim(); const preview = String(a?.preview || '').trim(); const files = Array.isArray(a?.files) ? a.files.slice(0, 12).map(x => String(x || '')).filter(Boolean) : []; const dirs = Array.isArray(a?.directories) ? a.directories.slice(0, 8).map(x => String(x || '')).filter(Boolean) : []; const locationTarget = toPath || pathVal || fromPath; return `
${escHtml(title)}
${escHtml(status)}
${pathVal ? `
Path: ${escHtml(pathVal)}
` : ''} ${fromPath ? `
From: ${escHtml(fromPath)}
` : ''} ${toPath ? `
To: ${escHtml(toPath)}
` : ''} ${summary ? `
${escHtml(summary)}
` : ''} ${files.length ? `
Files: ${escHtml(files.join(', '))}${Array.isArray(a?.files) && a.files.length > files.length ? ' ...' : ''}
` : ''} ${dirs.length ? `
Directories: ${escHtml(dirs.join(', '))}${Array.isArray(a?.directories) && a.directories.length > dirs.length ? ' ...' : ''}
` : ''} ${preview ? `
${escHtml(preview)}
` : ''} ${locationTarget ? `
` : ''}
`; }).join(''); return `
${cards}
`; } async function openInFileLocation(targetPath) { const p = String(targetPath || '').trim(); if (!p) return; try { const res = await fetch('/api/open-path', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ path: p }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data?.ok) { throw new Error(String(data?.error || `HTTP ${res.status}`)); } addProcessEntry('info', `Opened in file location: ${p}`); } catch (err) { addProcessEntry('error', `Open path failed: ${String(err?.message || err)}`); } } function renderChatMessages() { const container = document.getElementById('chat-messages'); if (chatHistory.length === 0) { container.innerHTML = `
🍒

CherryClaw

Chat directly with your local model. No API keys, no cloud — just you and the model.

Enable Agent mode when you want planning + tool execution in this chat.
`; return; } container.innerHTML = chatHistory.map((msg, idx) => { const pinClass = contextPinMode ? ' pin-selectable' : ''; const selectedClass = (contextPinMode && pinnedMessageIndices.has(idx)) ? ' pin-selected' : ''; // Also show green for confirmed pins even outside pin mode const confirmedClass = (!contextPinMode && confirmedPins.some(p => p.content === msg.content && p.role === (msg.role === 'user' ? 'user' : 'assistant'))) ? ' pin-selected' : ''; const clickHandler = contextPinMode ? ` onclick="togglePinMessage(${idx})"` : ''; if (msg.role === 'widget') { return `
${msg.content}
`; } return `
${msg.role === 'user' ? 'U' : ''}
${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}${msg.role === 'ai' ? ` ` : ''}
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `
${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}
`} ${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''} ${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''} ${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''} ${msg.role === 'ai' ? renderThinkBlock(msg.thinking) : ''} ${msg.role === 'ai' ? renderReactSteps(msg.steps) : ''}
`; }).join(''); validateDownloadLinks(container); if (isThinking && streamingSessionId === activeChatSessionId) { const progressHtml = currentProgressLines.length ? `
${currentProgressLines.map((l) => `
• ${escHtml(l)}
`).join('')}
` : ''; container.innerHTML += `
CherryClaw${useAgentMode ? ' (Agent)' : ''}
${currentPreflightStatus ? `
${escHtml(currentPreflightStatus)}
` : ''} ${progressHtml}
`; } container.scrollTop = container.scrollHeight; } function pushProgressLine(line) { const txt = String(line || '').trim(); if (!txt) return; const last = currentProgressLines[currentProgressLines.length - 1] || ''; if (last === txt) return; currentProgressLines.push(txt); if (currentProgressLines.length > 8) currentProgressLines = currentProgressLines.slice(-8); if (isThinking) renderChatMessages(); } // ---- Process log ---- function renderProcessPill(entries) { const id = 'proc_' + Math.random().toString(36).slice(2); return `
`; } function formatProcessLines(entries) { if (!entries || entries.length === 0) return '
No process details.
'; const TYPE_COLORS = { user: 'var(--text)', think: '#388bfd', tool: '#e3b341', result: '#bc8cff', error: 'var(--red)', final: '#56d364', warn: 'var(--yellow)', step: '#56d364', info: '#79c0ff', split: 'var(--accent2)', synth: 'var(--accent2)', }; return entries.map(e => { const rawType = String(e.type || 'info'); const typeLabel = escHtml(rawType).toUpperCase(); const typeColor = TYPE_COLORS[rawType] || 'var(--muted)'; const rawContent = String(e.content || ''); // Render markdown for result entries so download links show as clickable buttons const content = rawType === 'result' ? renderMarkdown(rawContent) : escHtml(rawContent); return `
[${escHtml(String(e.ts || ''))}] ${typeLabel} ${content}
`; }).join(''); } function toggleCurrentProcess() { const panel = document.getElementById('current-turn-process'); if (!panel) return; const open = panel.style.display !== 'none'; panel.style.display = open ? 'none' : 'block'; if (!open) { const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; panel.innerHTML = formatProcessLines(turnEntries); } } function isNearBottom(el, threshold = 40) { if (!el) return true; return (el.scrollHeight - (el.scrollTop + el.clientHeight)) <= threshold; } function setupProcessAndRightScrollTracking() { const processEl = document.getElementById('process-log'); if (processEl && !processEl.dataset.scrollBound) { processEl.dataset.scrollBound = '1'; processEl.addEventListener('scroll', () => { processLogAutoFollow = isNearBottom(processEl, 36); }); } const rightEl = document.getElementById('right-panel'); if (rightEl && !rightEl.dataset.scrollBound) { rightEl.dataset.scrollBound = '1'; rightEl.addEventListener('scroll', () => { rightColumnAutoFollow = isNearBottom(rightEl, 28); }); } } function maybeAutoScrollRightColumn(force = false) { const rightEl = document.getElementById('right-panel'); if (!rightEl) return; if (force || rightColumnAutoFollow || isThinking) { rightEl.scrollTop = rightEl.scrollHeight; } } function updateHeartbeatUI() { const stateEl = document.getElementById('r-heartbeat-state'); const metaEl = document.getElementById('r-heartbeat-meta'); if (stateEl) { const state = String(lastHeartbeat.state || 'idle').toLowerCase(); stateEl.textContent = state || 'idle'; stateEl.style.color = state === 'running' || state === 'retrying' ? 'var(--warn)' : (state === 'stalled' ? 'var(--err)' : 'var(--text)'); } if (metaEl) { const step = String(lastHeartbeat.current_step || '-'); const retries = Number(lastHeartbeat.retry_count || 0); const fv = Number(lastHeartbeat.format_violation_count || 0); const lvl = String(lastHeartbeat.level || '').toLowerCase(); const lvlTag = lvl ? ` ${lvl}` : ''; metaEl.textContent = `step:${step} • retries:${retries} • fv:${fv}${lvlTag}`; } } function clearProcessLog() { processLogEntries.length = 0; const el = document.getElementById('process-log'); if (el) el.innerHTML = '
활동 대기 중...
'; processLogAutoFollow = true; rightColumnAutoFollow = true; maybeAutoScrollRightColumn(true); persistActiveChat(); } function addProcessEntry(type, content, extra) { const ts = new Date().toLocaleTimeString(); processLogEntries.push({ ts, type, content, extra }); renderProcessLog(); maybeAutoScrollRightColumn(isThinking || rightColumnAutoFollow); persistActiveChat(); if (isThinking) { const panel = document.getElementById('current-turn-process'); if (panel && panel.style.display !== 'none' && currentTurnStartIndex >= 0) { panel.innerHTML = formatProcessLines(processLogEntries.slice(currentTurnStartIndex)); } } } function renderProcessLog() { const el = document.getElementById('process-log'); if (!el) return; setupProcessAndRightScrollTracking(); const shouldFollow = processLogAutoFollow || isThinking; if (processLogEntries.length === 0) { el.innerHTML = '
활동 대기 중...
'; if (shouldFollow) el.scrollTop = el.scrollHeight; return; } el.innerHTML = processLogEntries.map((e,i) => { let icon, color, label, labelColor; switch(e.type) { case 'user': icon='?'; color='var(--accent2)'; labelColor='var(--text)'; label='USER'; break; case 'think': icon='??'; color='#388bfd'; labelColor='#388bfd'; label='THINK'; break; case 'tool': icon='?'; color='#e3b341'; labelColor='#e3b341'; label='TOOL'; break; case 'result': icon='?'; color='#bc8cff'; labelColor='#bc8cff'; label='OK'; break; case 'error': icon='?'; color='var(--red)'; labelColor='var(--red)'; label='FAIL'; break; case 'final': icon='?'; color='#56d364'; labelColor='#56d364'; label='FINAL'; break; case 'warn': icon='?'; color='var(--yellow)'; labelColor='var(--yellow)'; label='WARN'; break; case 'step': icon='?'; color='#56d364'; labelColor='#56d364'; label='STEP'; break; case 'split': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SPLIT'; break; case 'synth': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SYNTH'; break; case 'info': icon='·'; color='#79c0ff'; labelColor='#79c0ff'; label='INFO'; break; default: icon='·'; color='var(--muted)'; labelColor='var(--muted)'; label=e.type; break; } // Think entries: show full content in a scrollable block if (e.type === 'think' && e.content && e.content.length > 60) { const id = 'pl_' + Math.random().toString(36).slice(2,8); const preview = e.content.slice(0, 80).replace(/\n/g,' '); return `
${e.ts} ${icon} ${label} ${escHtml(preview)}…
`; } // Tool result entries: collapsible if long if (e.type === 'result' && e.content && e.content.length > 120) { const id = 'pl_' + Math.random().toString(36).slice(2,8); const preview = e.content.slice(0, 100).replace(/\n/g,' '); return `
${e.ts} ${icon} ${label} ${escHtml(preview)}… [expand]
`; } // Memory suggestion: show approve/reject buttons if (e.type === 'memory') { const id = 'pl_mem_' + i; const suggest = e.extra || {}; return `
${e.ts} ${icon} ${label} ${escHtml(String(e.content || ''))}
`; } // If an 'extra' object/array was provided, show a details toggle if (e.extra) { const id = 'pl_extra_' + i; const pretty = escHtml(JSON.stringify(e.extra, null, 2)); return `
${e.ts} ${icon} ${label} ${escHtml(String(e.content || ''))} [details]
`; } return `
${e.ts} ${icon} ${label} ${escHtml(String(e.content || ''))}
`; }).join(''); if (shouldFollow) el.scrollTop = el.scrollHeight; } function togglePL(id) { const el = document.getElementById(id); if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none'; } function isFailedTurnReply(text) { const t = String(text || '').trim().toLowerCase(); if (!t) return true; if (/^blocked\b/.test(t)) return true; if (/^no response received\.?$/.test(t)) return true; if (/^connection error:/.test(t)) return true; if (/^sorry, i didn'?t catch that/.test(t)) return true; if (/could not|couldn't/.test(t) && /(verify|format|synthes|extract|read|write|tool|request)/.test(t)) return true; return false; } // ---- Send chat (SSE version) ---- async function sendChat(queuedMessage = null) { const input = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value; const message = String(raw || '').trim(); if (!message && stagedAttachments.length === 0) return; if (isThinking) { if (typeof queuedMessage === 'string') return; if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) { addProcessEntry('warn', `Queue full (${MAX_QUEUED_PROMPTS}). Wait for current run to finish.`); return; } queuedPrompts.push(message); addProcessEntry('info', `Queued prompt #${queuedPrompts.length}. It will run automatically next.`); input.value = ''; input.style.height = 'auto'; updateQueuedPromptUI(); return; } // 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만 let fullMessage = message; let displayMessage = message; if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') { const attachParts = stagedAttachments .filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함 .map(function(a) { return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')'); }); fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : ''); const compactParts = stagedAttachments .filter(function(a) { return a.type !== 'txt-auto'; }) .map(function(a) { // [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링 var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**'; // PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가 if (a.type === 'pdf' && a.txtUrl) { line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')'; } return line; }); displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : ''); clearStagedAttachments(); } chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage }); persistActiveChat(); if (typeof queuedMessage !== 'string') { input.value = ''; input.style.height = 'auto'; } isThinking = true; thinkingStartedAt = Date.now(); if (thinkingTicker) clearInterval(thinkingTicker); thinkingTicker = setInterval(() => { const el = document.getElementById('thinking-elapsed'); if (el) el.textContent = `${Math.round((Date.now() - thinkingStartedAt) / 1000)}s 경과`; }, 1000); streamingSessionId = activeChatSessionId; // lock bubble to this session setButtonState(true); currentPreflightStatus = ''; currentProgressLines = []; liveTaskSteps = []; if (typeof _v2LiveState !== 'undefined') { _v2LiveState.fileId = null; _v2LiveState.inBlock = false; } lastHeartbeat = { state: 'running', level: '', current_step: 'dispatch', retry_count: 0, format_violation_count: 0, message: 'Turn started', }; lastHeartbeatLogSignature = ''; processLogAutoFollow = true; rightColumnAutoFollow = true; updateHeartbeatUI(); renderLiveStepsPanel(); sendBtn.disabled = false; updateQueuedPromptUI(); renderChatMessages(); maybeAutoScrollRightColumn(true); currentTurnStartIndex = processLogEntries.length; addProcessEntry('user', message); // 서버(session.ts)가 세션 히스토리를 디스크에 영구 저장하므로 클라이언트 history 전송 불필요. // historyOverride를 보내면 서버 세션을 덮어써서 오히려 오작동 + 대용량 413 원인이 됨. const historyForAPI = undefined; const allSteps = []; let finalReply = ''; let finalArtifacts = []; const turnThinkingBuffer = []; const turnThinkingSeen = new Set(); let sawExecuteModeThisTurn = false; let sawToolActivityThisTurn = false; let turnFileLinks = []; const collectTurnThinking = (value) => { const text = String(value || '').trim(); if (!text) return; const key = text.replace(/\s+/g, ' ').trim(); if (!key || turnThinkingSeen.has(key)) return; turnThinkingSeen.add(key); turnThinkingBuffer.push(text); }; try { // Use SSE fetch — stream steps live as they arrive currentAbortController = new AbortController(); let partialContent = ''; const res = await fetch('/api/chat', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }), signal: currentAbortController.signal, body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined }) }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // keep incomplete line for (const line of lines) { if (!line.startsWith('data: ')) continue; let event; try { event = JSON.parse(line.slice(6)); } catch { continue; } switch (event.type) { case 'agent_mode': lastAgentMode = event.mode || '-'; lastTurnKind = event.turnKind || lastTurnKind; if (event.mode === 'execute') sawExecuteModeThisTurn = true; addProcessEntry( 'info', `Agent mode: ${event.mode || 'unknown'}${event.turnKind ? ` (${event.turnKind})` : ''}${event.switched_from ? ` | switched_from=${event.switched_from}` : ''}${event.route_target ? ` | route_target=${event.route_target}` : ''}${event.trigger ? ` | trigger=${event.trigger}` : ''}` ); if (event.mode === 'execute') renderLiveStepsPanel(); loadAgentSessionContext(); break; case 'session_mode_locked': addProcessEntry('info', `Session mode locked: ${event.mode || 'unknown'}`); break; case 'decomposed': addProcessEntry('split', `Split into ${event.questions.length} sub-questions: ${event.questions.map((q,i) => `Q${i+1}: ${q.slice(0,40)}`).join(' | ')}`); break; case 'thinking': if (event.thinking && String(event.thinking).trim()) { const thinkingText = String(event.thinking).trim(); collectTurnThinking(thinkingText); addProcessEntry('think', thinkingText); } break; case 'token': case 'text_delta': { const token = event.text || event.content || event.delta || ''; if (token) { partialContent += token; } break; } case 'info': if (event.message) addProcessEntry('info', String(event.message)); break; case 'heartbeat': { lastHeartbeat = { ...lastHeartbeat, state: event.state || lastHeartbeat.state, level: event.level || lastHeartbeat.level, current_step: event.current_step || lastHeartbeat.current_step, retry_count: Number(event.retry_count || lastHeartbeat.retry_count || 0), format_violation_count: Number(event.format_violation_count || lastHeartbeat.format_violation_count || 0), message: event.message || lastHeartbeat.message || '', }; updateHeartbeatUI(); const sig = `${lastHeartbeat.state}|${lastHeartbeat.level}|${lastHeartbeat.current_step}|${lastHeartbeat.retry_count}|${lastHeartbeat.format_violation_count}`; if (sig !== lastHeartbeatLogSignature) { const level = String(lastHeartbeat.level || '').toLowerCase(); const text = String(lastHeartbeat.message || `state=${lastHeartbeat.state} step=${lastHeartbeat.current_step}`).trim(); addProcessEntry(level === 'hard' ? 'warn' : 'info', `Heartbeat: ${text}`); lastHeartbeatLogSignature = sig; } break; } case 'ui_preflight': if (event.message) { currentPreflightStatus = String(event.message); pushProgressLine(currentPreflightStatus); renderChatMessages(); } break; case 'tool_call': { const action = String(event.action || '').trim(); const stepNum = Number(event.stepNum || 0); const args = (event.args && typeof event.args === 'object') ? event.args : null; const argsPreview = args ? JSON.stringify(args).slice(0, 260) : ''; const syntheticTag = event.synthetic ? ' [synthetic]' : ''; sawToolActivityThisTurn = true; if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}Running ${action}...`); upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), 'in_progress', 'running'); if (action) { addProcessEntry( 'tool', `${stepNum ? `Step ${stepNum}: ` : ''}${action}${syntheticTag}${argsPreview ? ` ${argsPreview}` : ''}`, args || undefined, ); } break; } case 'tool_result': { const action = String(event.action || '').trim(); const stepNum = Number(event.stepNum || 0); sawToolActivityThisTurn = true; const text = String(event.result || ''); const ok = event.error === true ? false : !/^ERROR:/i.test(text); const syntheticTag = event.synthetic ? ' [synthetic]' : ''; if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}${action} ${ok ? 'complete' : 'failed'}`); upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), ok ? 'done' : 'failed', ok ? 'completed' : text.slice(0, 120)); addProcessEntry( ok ? 'result' : 'error', `${stepNum ? `Step ${stepNum}: ` : ''}${action || 'tool'}${syntheticTag} => ${text || '(no output)'}`, ); loadAgentSessionContext(); // PPTX: capture extracted images if (ok && action === 'pdf_extract_images') pptxCaptureImages(text); break; } case 'orchestration': { try { if (typeof window !== 'undefined' && typeof window._orchEventHandler === 'function') { window._orchEventHandler(event); } } catch {} const trigger = String(event.trigger || 'unknown'); const mode = String(event.mode || 'rescue'); const route = event.route ? ` | route=${event.route}` : ''; const reason = event.reason ? ` | ${event.reason}` : ''; const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap))) ? ` | assists ${event.assist_count}/${event.assist_cap}` : ''; addProcessEntry('info', `Orchestrator: trigger=${trigger} mode=${mode}${route}${reason}${cap}`, event); if (event.preflight?.quick_plan?.length) { addProcessEntry('info', `Orchestrator plan: ${event.preflight.quick_plan[0]}`); } else if (event.advice?.next_actions?.length) { addProcessEntry('info', `Orchestrator next: ${event.advice.next_actions[0]}`); } const preflightRaw = String(event.preflight?.raw_response || '').trim(); if (preflightRaw) { addProcessEntry('think', `[Secondary AI preflight raw response]\n${preflightRaw}`); } const adviceRaw = String(event.advice?.raw_response || '').trim(); if (adviceRaw) { addProcessEntry('think', `[Secondary AI advisor raw response]\n${adviceRaw}`); } break; } case 'browser_advisor_start': { const pageType = event.page_type ? ` type=${event.page_type}` : ''; const count = Number.isFinite(Number(event.extracted_count)) ? ` | extracted=${event.extracted_count}` : ''; addProcessEntry('info', `Browser advisor start:${pageType}${count}`); break; } case 'feed_collected': { const b = Number(event.batch || 0); const added = Number(event.added || 0); const total = Number(event.total || 0); const deduped = Number(event.deduped || 0); addProcessEntry('info', `Feed collected: batch ${b} | +${added} | total ${total} | deduped ${deduped}`); break; } case 'browser_advisor_route': { const route = String(event.route || 'unknown'); const reason = event.reason ? ` | ${String(event.reason)}` : ''; const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap))) ? ` | assists ${event.assist_count}/${event.assist_cap}` : ''; const nextTool = event.next_tool?.tool ? ` | next=${event.next_tool.tool}` : ''; addProcessEntry('info', `Browser advisor route=${route}${nextTool}${reason}${cap}`, event); const rawResponse = String(event.raw_response || '').trim(); if (rawResponse) { addProcessEntry('think', `[Secondary AI browser advisor raw response]\n${rawResponse}`); } break; } case 'browser_advisor_nudge': { const route = event.route ? `[${event.route}] ` : ''; const preview = String(event.preview || '').trim(); if (preview) addProcessEntry('info', `Advisor nudge ${route}${preview}`); break; } case 'forced_retry': { const reason = String(event.reason || 'advisor requested continuation'); const retry = Number(event.retry || 0); const max = Number(event.max_retries || 0); addProcessEntry('warn', `Forced retry ${retry}/${max}: ${reason}`); break; } case 'preempt_start': { const elapsedSec = Math.max(1, Math.round(Number(event.elapsed_ms || 0) / 1000)); const thresholdSec = Math.max(1, Math.round(Number(event.threshold_ms || 0) / 1000)); addProcessEntry('warn', `Preempt start: generation stalled ${elapsedSec}s (threshold ${thresholdSec}s).`); break; } case 'preempt_killed': { const restarted = event.restarted === true; const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap))) ? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}` : ''; addProcessEntry(restarted ? 'info' : 'warn', `Preempt kill/restart ${restarted ? 'completed' : 'failed'}${cap}`); break; } case 'preempt_ready': { const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap))) ? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}` : ''; addProcessEntry('info', `Preempt ready: Ollama online, running rescue advisor${cap}`); break; } case 'preempt_rescue': { const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap))) ? ` | assists ${event.assist_count}/${event.assist_cap}` : ''; addProcessEntry('info', `Preempt rescue guidance injected${cap}`); break; } case 'preempt_retry': addProcessEntry('info', 'Preempt retry: re-running primary with rescue context.'); break; case 'synthesizing': pushProgressLine(`Synthesizing ${Number(event.count || 1)} answer(s)...`); addProcessEntry('synth', `Combining ${event.count} answers...`); break; case 'step': { const s = event; allSteps.push(s); // Show step number if (s.stepNum && !s.isFormatViolation && !s.finalAnswer && !s.action) { // just a step counter with no other info — skip } // Thinking block if (s.thinking) { collectTurnThinking(s.thinking); addProcessEntry('think', s.thinking); } // Format violation if (s.isFormatViolation) { addProcessEntry('warn', 'Format violation — retrying'); break; } // Tool call if (s.action && !s.toolResult) { sawToolActivityThisTurn = true; addProcessEntry('tool', `${s.action} ${JSON.stringify(s.params || {}).slice(0, 100)}`); if (s.thought) addProcessEntry('info', s.thought); } // Tool result (same step object updated) if (s.action && s.toolResult) { sawToolActivityThisTurn = true; const toolText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || ''); const isErr = toolText.startsWith('ERROR'); addProcessEntry(isErr ? 'error' : 'result', toolText); if (s.action === 'web_search') { const diag = s?.toolData?.search_diagnostics || s?.diagnostics || null; if (diag) { const query = String(diag.query || s?.params?.query || '').trim(); if (query) addProcessEntry('info', `Search query: ${query}`); if (Array.isArray(diag.attempted) && diag.attempted.length) { const providers = diag.attempted.map(a => { const p = String(a.provider || '').toLowerCase(); const status = String(a.status || '').toLowerCase(); if (status === 'success') { const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : ''; return `${p}=success${count}`; } if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`; return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`; }).join(' | '); addProcessEntry('info', `Providers: ${providers}`); } } } } // Final answer if (s.finalAnswer) { addProcessEntry('final', s.finalAnswer); partialContent = s.finalAnswer; // track for stop } break; } case 'memory_suggest': // event.suggestion: { fact, reference, source_tool, source_output, actor } const s = event.suggestion || {}; addProcessEntry('memory', s.fact || '(memory suggestion)', s); addProcessEntry('info', 'A memory suggestion was created; approve to persist.'); break; case 'memory_saved': addProcessEntry(event.ok ? 'result' : 'warn', event.ok ? `Memory updated${event.key ? ` (${event.key})` : ''}.` : 'Memory update failed.'); break; case 'web_search_snippets': // event: { query, snippets } const q = event.query || '(search)'; addProcessEntry('info', `Search results: ${q}`, event.snippets || []); if (event.diagnostics && Array.isArray(event.diagnostics.attempted)) { const providers = event.diagnostics.attempted.map(a => { const p = String(a.provider || '').toLowerCase(); const status = String(a.status || '').toLowerCase(); if (status === 'success') { const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : ''; return `${p}=success${count}`; } if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`; return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`; }).join(' | '); addProcessEntry('info', `Providers: ${providers}`); } break; case 'image': if (event.url) { pushProgressLine(`Image: ${event.alt || event.url}`); const imgAlt = String(event.alt || '').replace(/"/g, '"'); const imgErrAlt = String(event.alt || '이미지').replace(/'/g, ''').replace(/✖ ${imgErrAlt}를 불러올 수 없습니다.')`; const imgHtml = `${imgAlt}`; // Append image to current streaming AI message const lastAiMsg = chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'ai' ? chatHistory[chatHistory.length - 1] : null; if (lastAiMsg) { lastAiMsg.content += '\n\n' + imgHtml; } else { chatHistory.push({ role: 'ai', content: imgHtml }); } renderChatMessages(); } break; case 'files': if (Array.isArray(event.links)) { for (const lnk of event.links) turnFileLinks.push(lnk); } break; case 'error': addProcessEntry('error', event.message); break; case 'done': finalReply = event.reply || ''; if (finalReply) partialContent = finalReply; finalArtifacts = Array.isArray(event.artifacts) ? event.artifacts : []; loadAgentSessionContext(); renderLiveStepsPanel(); break; case 'turn_execution_created': case 'turn_execution_updated': loadAgentSessionContext(); break; } } } if (finalReply) { // Append file cards for any attachments sent via the 'files' SSE event if (turnFileLinks.length > 0) { const seen = new Set(); const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; }); // Only append links not already present as markdown in the reply const toAppend = newLinks.filter(l => !finalReply.includes(l.url || '')); if (toAppend.length > 0) { finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n'); } } const finalStep = allSteps.find(s => s.finalAnswer); const mergedThinking = turnThinkingBuffer.join('\n\n').trim(); const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn; const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; chatHistory.push({ role: 'ai', content: finalReply, artifacts: finalArtifacts, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', thinking: shouldAttachThinkingPanel ? (mergedThinking || finalStep?.thinking) : '', processEntries: turnEntries }); } else { const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; chatHistory.push({ role: 'ai', content: 'No response received.', processEntries: turnEntries }); } persistActiveChat(); // PPTX: detect pptx-outline block in final reply if (finalReply) { const outlineMatch = finalReply.match(/```pptx-outline\s*\n([\s\S]*?)```/); if (outlineMatch) { try { pptxSetOutline(JSON.parse(outlineMatch[1])); } catch {} } } // Auto-open code blocks from final reply in the editor if (finalReply && typeof codeAiRenderMarkdown === 'function') { const codeBlockRe = /```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```/g; let cbm; while ((cbm = codeBlockRe.exec(finalReply)) !== null) { const lang = cbm[1] || 'text'; const code = cbm[2]; if (codeIsOpenable(code, lang)) { codeAiOpenInEditor(code, lang); } } } } catch (err) { const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : []; if (err.name === 'AbortError') { addProcessEntry('warn', 'Generation stopped by user.'); const content = partialContent || (allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]'); chatHistory.push({ role: 'ai', content, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', processEntries: turnEntries }); } else { lastHeartbeat = { ...lastHeartbeat, state: 'stalled', level: 'hard', message: String(err.message || 'connection_error'), current_step: 'error' }; updateHeartbeatUI(); addProcessEntry('error', err.message); chatHistory.push({ role: 'ai', content: `Connection error: ${err.message}`, processEntries: turnEntries }); } persistActiveChat(); } isThinking = false; if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush(); if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; } streamingSessionId = null; // release session lock currentAbortController = null; setButtonState(false); currentPreflightStatus = ''; currentProgressLines = []; lastHeartbeat = { ...lastHeartbeat, state: 'idle', level: '', message: '', current_step: 'done' }; updateHeartbeatUI(); currentTurnStartIndex = -1; sendBtn.disabled = false; renderChatMessages(); updateQueuedPromptUI(); const shouldPauseQueue = isFailedTurnReply(finalReply || (chatHistory[chatHistory.length - 1]?.content || '')); if (queuedPrompts.length > 0 && shouldPauseQueue) { addProcessEntry('warn', 'Queue paused because the previous turn failed/blocked. Press Send to resume queued prompts.'); } else if (queuedPrompts.length > 0) { const next = queuedPrompts.shift(); updateQueuedPromptUI(); addProcessEntry('info', `Auto-running queued prompt${queuedPrompts.length ? ` (${queuedPrompts.length} remaining)` : ''}.`); setTimeout(() => { sendChat(next); }, 0); } } // ---- Agent Execution Tracking ---- function spawnAgentExecution(agentName, taskPrompt, isSubagent = false) { const agentId = (isSubagent ? 'sub_' : 'main_') + (++agentIdCounter); const agentData = { id: agentId, name: agentName, status: AGENT_STATUS.ACTIVE, task: taskPrompt, logs: [], isSubagent: isSubagent, startTime: new Date(), }; agentExecutionMap.set(agentId, agentData); renderAgentExecutionPanel(); return agentId; } function addAgentLog(agentId, type, content) { const agentData = agentExecutionMap.get(agentId); if (!agentData) return; const ts = new Date().toLocaleTimeString(); agentData.logs.push({ ts, type, content }); // Keep only last 20 logs per agent if (agentData.logs.length > 20) agentData.logs.shift(); renderAgentExecutionPanel(); } function completeAgentExecution(agentId) { const agentData = agentExecutionMap.get(agentId); if (!agentData) return; agentData.status = AGENT_STATUS.COMPLETED; renderAgentExecutionPanel(); } function pauseAgentExecution(agentId, reason) { const agentData = agentExecutionMap.get(agentId); if (!agentData) return; agentData.status = AGENT_STATUS.PAUSED; if (reason) addAgentLog(agentId, 'error', reason); renderAgentExecutionPanel(); } function renderAgentExecutionPanel() { const panel = document.getElementById('agent-execution-panel'); if (!panel) return; if (agentExecutionMap.size === 0) { panel.innerHTML = '
작업 실행 대기 중...
'; return; } const agents = Array.from(agentExecutionMap.values()).sort((a, b) => a.startTime - b.startTime); panel.innerHTML = agents.map((agent) => { const statusClass = agent.status === AGENT_STATUS.ACTIVE ? 'active' : agent.status === AGENT_STATUS.COMPLETED ? 'completed' : 'paused'; const arrow = agent.expanded ? '▼' : '▶'; const logsHtml = agent.logs.length > 0 ? agent.logs.map(log => `
${escHtml(log.ts)}[${escHtml(log.type.toUpperCase())}]${escHtml(log.content)}
`).join('') : '
No logs yet
'; return `
${arrow}
${escHtml(agent.isSubagent ? '└─ ' + agent.name : agent.name)}
${agent.status.toUpperCase()}
${escHtml(agent.task.substring(0, 70))}${agent.task.length > 70 ? '...' : ''}
${logsHtml}
`; }).join(''); } function toggleAgentPanel(agentId) { const agentData = agentExecutionMap.get(agentId); if (!agentData) return; agentData.expanded = !agentData.expanded; renderAgentExecutionPanel(); } function openProcessLogFile() { // Call server endpoint to open the process log file if (activeTaskId) { fetch(`/api/open-file?task=${encodeURIComponent(activeTaskId)}&type=process`, { headers: authHeaders() }) .catch(err => console.error('Unable to open process log:', err)); } } function clearChat() { chatHistory = []; persistActiveChat(); renderChatMessages(); } function toggleExportMenu(e) { if (e) e.stopPropagation(); var menu = document.getElementById('export-menu'); if (!menu) return; var opening = menu.style.display === 'none' || !menu.style.display; menu.style.display = opening ? 'block' : 'none'; if (opening) { setTimeout(function() { document.addEventListener('click', function closeExport() { menu.style.display = 'none'; document.removeEventListener('click', closeExport); }); }, 0); } } function exportChat(format) { var menu = document.getElementById('export-menu'); if (menu) menu.style.display = 'none'; if (!chatHistory || !chatHistory.length) { alert('내보낼 채팅 내용이 없습니다.'); return; } var sess = chatSessions.find(function(s) { return s.id === activeChatSessionId; }); var title = (sess && sess.title) ? sess.title : '채팅'; var dateStr = new Date().toLocaleString('ko-KR'); var safeName = title.replace(/[^\w가-힣\s-]/g, '').trim().replace(/\s+/g, '_').slice(0, 40) || 'chat'; var dateSuffix = new Date().toISOString().slice(0, 10); var filename = safeName + '_' + dateSuffix; var content, mime, ext; if (format === 'json') { content = JSON.stringify({ title: title, exportedAt: new Date().toISOString(), sessionId: activeChatSessionId, messages: chatHistory.map(function(m) { return { role: m.role, content: (m.display || m.content || '').trim() }; }) }, null, 2); mime = 'application/json'; ext = 'json'; } else if (format === 'md') { var lines = ['# ' + title, '', '> 내보낸 시간: ' + dateStr, '']; chatHistory.forEach(function(m) { var label = m.role === 'user' ? '## 사용자' : '## 어시스턴트'; var text = (m.display || m.content || '').trim(); lines.push(label, '', text, '', '---', ''); }); content = lines.join('\n'); mime = 'text/markdown'; ext = 'md'; } else { var lines = [title, '내보낸 시간: ' + dateStr, '='.repeat(50), '']; chatHistory.forEach(function(m) { var label = m.role === 'user' ? '[사용자]' : '[AI]'; var text = (m.display || m.content || '').trim() .replace(/!\[[^\]]*\]\([^)]*\)/g, '[이미지]') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[*_`#>]/g, ''); lines.push(label, text, ''); }); content = lines.join('\n'); mime = 'text/plain'; ext = 'txt'; } var blob = new Blob([content], { type: mime + '; charset=utf-8' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename + '.' + ext; document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } // Enter to send, Shift+Enter for newline document.getElementById('chat-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } }); // Auto-resize textarea document.getElementById('chat-input').addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 140) + 'px'; }); // ---- WebSocket ---- 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.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 = () => 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 = '/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; } const LAST_USER_KEY = 'cherryclaw_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})`); } 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 = '/login.html'; return; } if (data.enabled && !data.authenticated) { window.location.href = '/login.html'; return; } if (data.authenticated && data.username) { currentUser = { username: data.username, role: data.role }; // 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(); // 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 = '/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('cherryclaw_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', 'ppt', '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 === 'ppt') loadPptTab(); if (t === 'users') loadUsersTab(); if (t === 'voice') loadVoiceSettings(); if (t === 'session') loadSessionSettings(); if (t === 'context-viewer') loadContextViewerTab(); } else { panel.style.display = 'none'; } } }); } // --- Credentials Tab --------------------------------------------------------- async function loadCredentialsTab() { await Promise.all([loadCredFields(), loadCredVaultStatus(), loadCredVaultLog()]); } 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}`; } } 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', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic']; 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-llamacpp-endpoint', pc.llama_cpp?.endpoint); v('settings-llamacpp-model', pc.llama_cpp?.model); v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint); v('settings-lmstudio-model', pc.lm_studio?.model); v('settings-openai-key', pc.openai?.api_key); v('settings-anthropic-key', pc.anthropic?.api_key); if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; } // Set primary model from saved config before refreshing the list // Fallback to data.models_primary (models.primary) when provider's model field is empty const savedPrimaryModel = pc[prov]?.model || data?.models_primary || ''; if (savedPrimaryModel) { const sel = document.getElementById('settings-primary-model'); if (sel) sel.value = savedPrimaryModel; } if (pc.openai?.model) { const s = document.getElementById('settings-openai-model'); if (s) s.value = pc.openai.model; } if (pc.openai_codex?.model) { const s = document.getElementById('settings-codex-model'); if (s) s.value = pc.openai_codex.model; } // Check Codex OAuth status await refreshCodexStatus(); // Refresh OpenAI model dropdown from current key when applicable if (prov === 'openai') await refreshOpenAIModels(true); // Auto-load model list for list-capable providers (preserves saved selection) if (['ollama', 'lm_studio', 'llama_cpp'].includes(prov)) await refreshProviderModels(savedPrimaryModel); _modelSettingsLoaded = true; } catch (e) { console.warn('loadModelSettings error:', e); } } async function refreshProviderModels(forcedValue) { const provider = document.getElementById('settings-llm-provider')?.value || 'ollama'; const msg = document.getElementById('provider-status-msg'); if (msg) msg.textContent = '모델 로딩 중…'; try { const llm = buildProviderPayload(); if (llm.provider !== provider) llm.provider = provider; const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) }); const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m))); const selects = ['settings-primary-model']; selects.forEach((selId, i) => { const sel = document.getElementById(selId); if (!sel) return; if (i === 0) { // forcedValue: saved model passed from loadModelSettings (DOM value not yet set when options are empty) const prev = forcedValue || String(sel.value || '').trim(); sel.innerHTML = models.map(m => ``).join(''); if (models.length) sel.value = (prev && models.includes(prev)) ? prev : models[0]; } }); if (msg) msg.textContent = models.length ? `${models.length}개 모델 발견.` : '모델 없음 — 서버가 실행 중인가요?'; } catch (e) { if (msg) msg.textContent = '모델 가져오기 실패.'; } } async function testProviderConnection() { const provider = document.getElementById('settings-llm-provider').value; const statusIds = { ollama: 'provider-status-msg', 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' }; 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.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 || '' }; 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' }; 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 || '삭제 실패'); } } // --- Per-User Telegram Bot --------------------------------------------------- function toggleUserTelegram(username) { const panel = document.getElementById(`user-telegram-panel-${username}`); if (!panel) return; if (panel.style.display === 'none') { panel.style.display = 'block'; loadUserTelegramBot(username); } else { panel.style.display = 'none'; } } async function loadUserTelegramBot(username) { const panel = document.getElementById(`user-telegram-panel-${username}`); if (!panel) return; panel.innerHTML = '
불러오는 중...
'; try { const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`); const statusColor = data.running ? 'var(--ok)' : 'var(--muted)'; const statusText = data.running ? `연결됨 (@${data.botUsername || '?'})` : '중지됨'; panel.innerHTML = `
텔레그램 전용 봇 — ${username}
● ${statusText}
${data.hasToken ? `` : ''}
`; } catch (e) { panel.innerHTML = `
${e.message}
`; } } async function saveUserTelegramBot(username) { const input = document.getElementById(`tg-token-${username}`); const msg = document.getElementById(`tg-bot-msg-${username}`); const token = input ? input.value.trim() : ''; if (!token) { if (msg) msg.textContent = '토큰을 입력해 주세요.'; return; } try { const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ botToken: token }), }); if (data.success) { if (msg) { msg.style.color = 'var(--ok)'; msg.textContent = '저장됨. 봇 연결 중...'; } setTimeout(() => loadUserTelegramBot(username), 2000); } else { if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '저장 실패'; } } } catch (e) { if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; } } } async function deleteUserTelegramBot(username) { if (!confirm(`${username}의 텔레그램 봇을 삭제하시겠습니까?`)) return; const msg = document.getElementById(`tg-bot-msg-${username}`); try { const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ botToken: '' }), }); if (data.success) loadUserTelegramBot(username); else if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '삭제 실패'; } } catch (e) { if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; } } } // --- Per-User Email Accounts ------------------------------------------------- function toggleUserEmail(username) { const panel = document.getElementById(`user-email-panel-${username}`); if (!panel) return; if (panel.style.display === 'none') { panel.style.display = 'block'; loadUserEmailAccounts(username); } else { panel.style.display = 'none'; } } function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } async function loadUserEmailAccounts(username) { const panel = document.getElementById(`user-email-panel-${username}`); if (!panel) return; panel.innerHTML = '
불러오는 중…
'; try { const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`); const accounts = data.accounts || []; const def = data.default || ''; const formId = `email-form-${username}`; const list = accounts.length === 0 ? '
등록된 이메일 계정이 없습니다.
' : accounts.map(a => `
${escapeHtml(a.id)} ${def === a.id ? '기본' : ''} ${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port} ${a.password_set ? '' : '비번 없음'} ${def !== a.id ? `` : ''}
`).join(''); panel.innerHTML = `
이메일 계정 (${accounts.length})
${list}
+ 계정 추가/수정
`; } catch (e) { panel.innerHTML = `
불러오기 실패: ${escapeHtml(e.message || e)}
`; } } async function submitUserEmailAccount(username) { const form = document.getElementById(`email-form-${username}`); const msg = document.getElementById(`email-form-${username}-msg`); if (!form) return; const get = (k) => form.querySelector(`[data-f="${k}"]`); const id = (get('id').value || '').trim(); if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { msg.textContent = '계정 ID는 1-32자 영숫자/대시/언더스코어'; return; } const body = { imap_host: get('imap_host').value.trim(), imap_port: Number(get('imap_port').value) || 993, imap_tls: get('imap_tls').checked, smtp_host: get('smtp_host').value.trim(), smtp_port: Number(get('smtp_port').value) || 587, smtp_secure: get('smtp_secure').checked, username: get('username').value.trim(), password: get('password').value, }; if (!body.imap_host || !body.smtp_host || !body.username) { msg.textContent = 'IMAP/SMTP 호스트와 사용자명은 필수'; return; } msg.textContent = '저장 중…'; msg.style.color = 'var(--muted)'; try { await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(body), }); loadUserEmailAccounts(username); } catch (e) { msg.textContent = e.message || '저장 실패'; msg.style.color = 'var(--err)'; } } async function deleteUserEmailAccount(username, accountId) { if (!confirm(`계정 "${accountId}" 삭제하시겠습니까? (vault의 비밀번호도 함께 삭제됩니다)`)) return; try { await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' }); loadUserEmailAccounts(username); } catch (e) { alert(e.message || '삭제 실패'); } } async function setDefaultUserEmailAccount(username, accountId) { try { await api(`/api/users/${encodeURIComponent(username)}/email/default`, { method: 'PUT', body: JSON.stringify({ accountId }), }); loadUserEmailAccounts(username); } catch (e) { alert(e.message || '기본 설정 실패'); } } async function testUserEmailAccount(username, accountId, btn) { const orig = btn.textContent; btn.disabled = true; btn.textContent = '연결 중…'; try { const r = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST' }); if (r.success) { btn.textContent = '✓ 성공'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); } else { alert(`테스트 실패: ${r.error || 'unknown'}`); btn.textContent = orig; btn.disabled = false; } } catch (e) { alert(e.message || '테스트 실패'); btn.textContent = orig; btn.disabled = false; } } // --- 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} ${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 || '알 수 없는 오류'}
`; } } function escHtml(s) { return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"'); } // --- PPT Template & Skin Tab --------------------------------------------------- var pptTemplates = []; var pptSkins = []; var selectedPptTemplate = 'business'; var selectedPptSkin = ''; async function loadPptTab() { try { const [tRes, sRes, cfg] = await Promise.all([ api('/api/ppt/templates'), api('/api/ppt/skins'), api('/api/settings/ppt').catch(() => ({})), ]); pptTemplates = tRes.templates || []; pptSkins = sRes.skins || []; selectedPptTemplate = cfg.template || 'business'; selectedPptSkin = cfg.skin || ''; renderPptTemplates(); renderPptSkins(); } catch (e) { console.error('loadPptTab', e); } } function renderPptTemplates() { var grid = document.getElementById('ppt-templates-grid'); if (!grid) return; grid.innerHTML = pptTemplates.map(function(t) { var sel = t.name.toLowerCase() === selectedPptTemplate; var c = t.colors || {}; return '
' + '
' + escHtml(t.name) + '
' + '
' + escHtml(t.description || '') + '
' + '
' + '' + '' + '' + '' + '
' + (sel ? '
선택됨
' : '') + '
'; }).join(''); } function renderPptSkins() { var grid = document.getElementById('ppt-skins-grid'); if (!grid) return; grid.innerHTML = '
' + '
없음
' + (selectedPptSkin === '' ? '
선택됨
' : '') + '
' + pptSkins.map(function(s) { var sel = s.name === selectedPptSkin; return '
' + '' + '
' + escHtml(s.name) + '
' + (sel ? '
선택됨
' : '') + '
'; }).join(''); } function selectPptTemplate(name) { selectedPptTemplate = name; renderPptTemplates(); savePptPrefs(); } function selectPptSkin(name) { selectedPptSkin = name; renderPptSkins(); savePptPrefs(); } async function savePptPrefs() { try { await fetch('/api/settings/ppt', { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ template: selectedPptTemplate, skin: selectedPptSkin }), }); } catch (e) { console.error('savePptPrefs', e); } } function closeSettings() { document.getElementById('settings-modal').style.display = 'none'; _searchSettingsLoaded = false; _modelSettingsLoaded = false; } function ensureAgentMdEditor() { if (agentMdEditor || typeof CodeMirror === 'undefined') return; const wrap = document.getElementById('agent-md-editor-wrap'); if (!wrap) return; agentMdEditor = CodeMirror(wrap, { value: '', mode: 'markdown', lineNumbers: true, lineWrapping: true, tabSize: 2, indentWithTabs: false, theme: 'default', }); agentMdEditor.setSize('100%', 240); } function getAgentFromForm() { const profile = document.getElementById('agent-edit-profile').value.trim(); const maxStepsRaw = document.getElementById('agent-edit-max-steps').value; const maxSteps = Number(maxStepsRaw); const spawnAllowlist = (document.getElementById('agent-edit-spawn-allowlist').value || '') .split(',') .map(s => s.trim()) .filter(Boolean); const agent = { id: document.getElementById('agent-edit-id').value.trim(), name: document.getElementById('agent-edit-name').value.trim(), emoji: document.getElementById('agent-edit-emoji').value.trim(), description: document.getElementById('agent-edit-description').value.trim(), workspace: document.getElementById('agent-edit-workspace').value.trim(), model: document.getElementById('agent-edit-model').value.trim(), cronSchedule: document.getElementById('agent-edit-cron').value.trim(), minimalPrompt: document.getElementById('agent-edit-minimal').checked, default: document.getElementById('agent-edit-default').checked, canSpawn: document.getElementById('agent-edit-can-spawn').checked, spawnAllowlist, tools: {}, }; if (profile) agent.tools.profile = profile; if (Number.isFinite(maxSteps) && maxSteps > 0) agent.maxSteps = Math.floor(maxSteps); if (!agent.tools.profile) delete agent.tools; if (!agent.spawnAllowlist.length) delete agent.spawnAllowlist; return agent; } function setAgentForm(agent) { const a = agent || {}; document.getElementById('agent-edit-id').value = a.id || ''; document.getElementById('agent-edit-name').value = a.name || ''; document.getElementById('agent-edit-emoji').value = a.emoji || ''; document.getElementById('agent-edit-description').value = a.description || ''; document.getElementById('agent-edit-workspace').value = a.workspace || ''; document.getElementById('agent-edit-model').value = a.model || ''; document.getElementById('agent-edit-cron').value = a.cronSchedule || ''; document.getElementById('agent-edit-max-steps').value = (a.maxSteps || '') + ''; document.getElementById('agent-edit-default').checked = a.default === true; document.getElementById('agent-edit-minimal').checked = a.minimalPrompt === true; document.getElementById('agent-edit-can-spawn').checked = a.canSpawn === true; document.getElementById('agent-edit-spawn-allowlist').value = Array.isArray(a.spawnAllowlist) ? a.spawnAllowlist.join(',') : ''; document.getElementById('agent-edit-profile').value = a.tools?.profile || ''; } function renderAgentsList() { const el = document.getElementById('settings-agents-list'); if (!el) return; if (!agentsConfigList.length) { el.innerHTML = '
No agents found.
'; return; } el.innerHTML = agentsConfigList.map((a) => { const selected = a.id === selectedAgentId; const lastRun = a.lastRun?.finishedAt ? new Date(a.lastRun.finishedAt).toLocaleString() : 'never'; const heartbeat = a.lastHeartbeatAt ? new Date(a.lastHeartbeatAt).toLocaleString() : 'never'; const badge = a.default ? 'default' : ''; return ` `; }).join(''); } function findSelectedAgent() { return agentsConfigList.find(a => a.id === selectedAgentId) || null; } function agentFormNew() { selectedAgentId = ''; setAgentForm({ id: '', name: '', emoji: '', description: '', workspace: '', model: '', cronSchedule: '', maxSteps: '', default: false, minimalPrompt: true, canSpawn: false, spawnAllowlist: [], tools: {}, }); if (agentMdEditor) agentMdEditor.setValue(''); const mdPath = document.getElementById('agent-md-path'); if (mdPath) mdPath.textContent = '에이전트를 선택하거나 저장하면 AGENTS.md를 불러옵니다'; const resultEl = document.getElementById('agent-spawn-result'); if (resultEl) resultEl.textContent = ''; renderAgentsList(); } async function selectAgent(id) { selectedAgentId = id; const selected = findSelectedAgent(); setAgentForm(selected); renderAgentsList(); await loadSelectedAgentMd(); await loadAgentRunHistory(); } async function loadAgentsTab() { ensureAgentMdEditor(); try { const data = await api('/api/agents'); agentsConfigList = Array.isArray(data?.agents) ? data.agents : []; if (!selectedAgentId || !agentsConfigList.some(a => a.id === selectedAgentId)) { selectedAgentId = data?.defaultAgentId || agentsConfigList[0]?.id || ''; } renderAgentsList(); const selected = findSelectedAgent(); setAgentForm(selected); if (selected) { await loadSelectedAgentMd(); await loadAgentRunHistory(); } else { agentFormNew(); } } catch (err) { addProcessEntry('error', `Failed to load agents: ${err.message}`); } } async function saveAgentFromForm() { const agent = getAgentFromForm(); if (!agent.id) { alert('Agent ID is required'); return; } if (!agent.name) { alert('Agent name is required'); return; } try { const exists = agentsConfigList.some(a => a.id === agent.id); const endpoint = exists ? `/api/agents/${encodeURIComponent(agent.id)}` : '/api/agents'; const method = exists ? 'PUT' : 'POST'; const data = await api(endpoint, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent }), }); if (!data?.success) throw new Error(data?.error || 'save failed'); selectedAgentId = data?.agent?.id || agent.id; addProcessEntry('info', `Agent "${selectedAgentId}" saved.`); await loadAgentsTab(); } catch (err) { alert(`Failed to save agent: ${err.message}`); } } async function deleteSelectedAgent() { if (!selectedAgentId) return; if (!confirm(`Delete agent "${selectedAgentId}"?`)) return; try { await api(`/api/agents/${encodeURIComponent(selectedAgentId)}`, { method: 'DELETE' }); addProcessEntry('info', `Agent "${selectedAgentId}" deleted.`); selectedAgentId = ''; await loadAgentsTab(); } catch (err) { alert(`Failed to delete agent: ${err.message}`); } } async function loadSelectedAgentMd() { ensureAgentMdEditor(); if (!selectedAgentId || !agentMdEditor) return; try { const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`); agentMdEditor.setValue(data?.content || ''); const pathEl = document.getElementById('agent-md-path'); if (pathEl) pathEl.textContent = data?.path || ''; } catch (err) { addProcessEntry('error', `Failed to load AGENTS.md: ${err.message}`); } } async function saveSelectedAgentMd() { if (!selectedAgentId || !agentMdEditor) return; try { const content = agentMdEditor.getValue(); await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), }); addProcessEntry('final', `Saved AGENTS.md for "${selectedAgentId}".`); } catch (err) { alert(`Failed to save AGENTS.md: ${err.message}`); } } async function runSelectedAgentOnce() { if (!selectedAgentId) return; const task = (document.getElementById('agent-spawn-task').value || '').trim(); if (!task) { alert('Provide a task first.'); return; } const out = document.getElementById('agent-spawn-result'); if (out) out.textContent = '실행 중...'; try { const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/spawn`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task }), }); const result = data?.result || {}; if (out) { const head = result.success ? `Success (${result.durationMs || 0}ms)` : `Failed: ${result.error || 'unknown error'}`; out.textContent = `${head}\n\n${String(result.result || '').slice(0, 2500)}`; } await loadAgentsTab(); } catch (err) { if (out) out.textContent = `Run failed: ${err.message}`; } } async function loadAgentRunHistory() { const el = document.getElementById('agent-run-history'); if (!el) return; if (!selectedAgentId) { el.innerHTML = ''; return; } try { const data = await api(`/api/agents/history?agentId=${encodeURIComponent(selectedAgentId)}&limit=12`); const rows = Array.isArray(data?.history) ? data.history : []; if (!rows.length) { el.innerHTML = '
No runs yet.
'; return; } el.innerHTML = rows.map((r) => { const color = r.success ? '#1a6e35' : '#9c1a1a'; const when = new Date(r.finishedAt || r.startedAt || Date.now()).toLocaleString(); const label = `${r.trigger || 'manual'} • ${r.durationMs || 0}ms`; const preview = String(r.resultPreview || r.error || '').slice(0, 140); return `
${r.success ? 'success' : 'failed'} ${escHtml(when)}
${escHtml(label)}
${escHtml(preview)}
`; }).join(''); } catch (err) { el.innerHTML = `
Failed to load history: ${escHtml(err.message)}
`; } } // --- Channels Settings Functions --------------------------------------- let channelsStatusLoaded = false; function setChannelStatus(idPrefix, state, message) { const dot = document.getElementById(`${idPrefix}-status-dot`); const text = document.getElementById(`${idPrefix}-status-text`); if (!dot || !text) return; if (state === 'ok') { dot.style.background = 'var(--ok)'; text.style.color = 'var(--text)'; } else if (state === 'warn') { dot.style.background = '#f0ad4e'; text.style.color = '#b87b00'; } else { dot.style.background = '#ccc'; text.style.color = 'var(--muted)'; } text.textContent = message || '미설정'; } function readChannelPayload(channel) { if (channel === 'telegram') { const userIdStr = (document.getElementById('settings-tg-userid')?.value || '').trim(); const allowedUserIds = userIdStr ? userIdStr.split(/[,\s]+/).map(Number).filter(n => !isNaN(n) && n > 0) : []; return { enabled: !!document.getElementById('settings-tg-enabled')?.checked, botToken: (document.getElementById('settings-tg-token')?.value || '').trim(), allowedUserIds, streamMode: 'full', }; } if (channel === 'discord') { return { enabled: !!document.getElementById('settings-dc-enabled')?.checked, botToken: (document.getElementById('settings-dc-token')?.value || '').trim(), applicationId: (document.getElementById('settings-dc-appid')?.value || '').trim(), guildId: (document.getElementById('settings-dc-guildid')?.value || '').trim(), channelId: (document.getElementById('settings-dc-channelid')?.value || '').trim(), webhookUrl: (document.getElementById('settings-dc-webhook')?.value || '').trim(), }; } return { enabled: !!document.getElementById('settings-wa-enabled')?.checked, accessToken: (document.getElementById('settings-wa-token')?.value || '').trim(), phoneNumberId: (document.getElementById('settings-wa-phoneid')?.value || '').trim(), businessAccountId: (document.getElementById('settings-wa-baid')?.value || '').trim(), verifyToken: (document.getElementById('settings-wa-verify')?.value || '').trim(), webhookSecret: (document.getElementById('settings-wa-secret')?.value || '').trim(), testRecipient: (document.getElementById('settings-wa-recipient')?.value || '').trim(), }; } function setButtonBusy(id, busy, busyLabel, normalLabel) { const btn = document.getElementById(id); if (!btn) return; btn.disabled = !!busy; btn.textContent = busy ? busyLabel : normalLabel; } function getSelectedChannelType() { return String(document.getElementById('settings-channel-select')?.value || 'telegram'); } function onChannelTypeChange() { const ch = getSelectedChannelType(); const formMap = { telegram: 'channel-form-telegram', discord: 'channel-form-discord', whatsapp: 'channel-form-whatsapp', email: 'channel-form-email', }; const guideMap = { telegram: 'channel-guide-telegram', discord: 'channel-guide-discord', whatsapp: 'channel-guide-whatsapp', email: 'channel-guide-email', }; const statusMap = { telegram: 'tg-status-bar', discord: 'dc-status-bar', whatsapp: 'wa-status-bar', email: 'em-status-bar', }; ['telegram', 'discord', 'whatsapp', 'email'].forEach((k) => { const form = document.getElementById(formMap[k]); const guide = document.getElementById(guideMap[k]); const status = document.getElementById(statusMap[k]); if (form) form.style.display = (k === ch) ? 'block' : 'none'; if (guide) guide.style.display = (k === ch) ? 'block' : 'none'; if (status) status.style.display = (k === ch) ? 'flex' : 'none'; }); // Email: hide test/send-test buttons (email has per-account test) const testBtn = document.getElementById('channel-test-btn'); const sendTestBtn = document.getElementById('channel-send-test-btn'); if (testBtn) testBtn.style.display = (ch === 'email') ? 'none' : ''; if (sendTestBtn) sendTestBtn.style.display = (ch === 'email') ? 'none' : ''; // Load email accounts when switching to email tab if (ch === 'email') loadEmailAccounts(); } async function saveSelectedChannelSettings() { await saveChannelSettings(getSelectedChannelType()); } async function testSelectedChannel() { await testChannel(getSelectedChannelType()); } async function sendSelectedChannelTest() { await sendChannelTest(getSelectedChannelType()); } async function loadChannelsStatus() { try { const data = await api('/api/channels/status'); if (!data?.success) return; const tg = data.telegram || {}; const dc = data.discord || {}; const wa = data.whatsapp || {}; if (tg.connected && tg.polling) setChannelStatus('tg', 'ok', `Connected as @${tg.username || 'bot'}`); else if (tg.hasToken) setChannelStatus('tg', 'warn', 'Token saved, not connected'); else setChannelStatus('tg', 'off', '미설정'); if (dc.hasToken || dc.hasWebhook) setChannelStatus('dc', 'warn', 'Credentials saved'); else setChannelStatus('dc', 'off', '미설정'); if (wa.hasAccessToken && wa.phoneNumberId) setChannelStatus('wa', 'warn', 'Credentials saved'); else setChannelStatus('wa', 'off', '미설정'); if (!channelsStatusLoaded) { channelsStatusLoaded = true; if (tg.hasToken) document.getElementById('settings-tg-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)'; if (dc.hasToken) document.getElementById('settings-dc-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)'; if (wa.hasAccessToken) document.getElementById('settings-wa-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)'; document.getElementById('settings-tg-enabled').checked = !!tg.enabled; document.getElementById('settings-tg-userid').value = (tg.allowedUserIds || []).join(', '); document.getElementById('settings-dc-enabled').checked = !!dc.enabled; document.getElementById('settings-dc-appid').value = dc.applicationId || ''; document.getElementById('settings-dc-guildid').value = dc.guildId || ''; document.getElementById('settings-dc-channelid').value = dc.channelId || ''; document.getElementById('settings-wa-enabled').checked = !!wa.enabled; document.getElementById('settings-wa-phoneid').value = wa.phoneNumberId || ''; document.getElementById('settings-wa-baid').value = wa.businessAccountId || ''; document.getElementById('settings-wa-recipient').value = wa.testRecipient || ''; } onChannelTypeChange(); } catch (err) { console.error('[Channels] Status load failed:', err); } // Load heartbeat notify toggle try { const hbData = await api('/api/heartbeat/config'); if (hbData?.success) { document.getElementById('settings-hb-notify').checked = hbData.config.notifyEnabled !== false; updateHbNotifyLabel(); } } catch {} } async function saveChannelSettings(channel) { const payload = readChannelPayload(channel); try { const data = await api('/api/channels/config', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ channels: { [channel]: payload } }), }); if (data?.success) { addProcessEntry('final', `${channel} settings saved.`); channelsStatusLoaded = false; await loadChannelsStatus(); } else { alert('Save failed: ' + (data?.error || 'unknown error')); } } catch (err) { alert('Save failed: ' + err.message); } } async function testChannel(channel) { const btnId = document.getElementById('channel-test-btn') ? 'channel-test-btn' : channel === 'telegram' ? 'tg-test-btn' : channel === 'discord' ? 'dc-test-btn' : 'wa-test-btn'; setButtonBusy(btnId, true, 'Testing...', 'Test'); try { const payload = readChannelPayload(channel); const data = await api(`/api/channels/test/${channel}`, { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload), }); if (data?.success) { if (channel === 'telegram') setChannelStatus('tg', 'ok', `Valid bot @${data?.bot?.username || 'telegram'}`); if (channel === 'discord') setChannelStatus('dc', 'ok', `Valid bot @${data?.bot?.username || 'discord'}`); if (channel === 'whatsapp') setChannelStatus('wa', 'ok', `Valid phone ${data?.account?.display_phone_number || data?.account?.id || ''}`.trim()); addProcessEntry('final', `${channel} connection test passed.`); } else { alert(`${channel} test failed: ${data?.error || 'unknown error'}`); } } catch (err) { alert(`${channel} test failed: ${err.message}`); } setButtonBusy(btnId, false, 'Testing...', 'Test'); } async function sendChannelTest(channel) { const btnId = document.getElementById('channel-send-test-btn') ? 'channel-send-test-btn' : channel === 'telegram' ? 'tg-send-test-btn' : channel === 'discord' ? 'dc-send-test-btn' : 'wa-send-test-btn'; setButtonBusy(btnId, true, 'Sending...', 'Send Test'); try { const payload = readChannelPayload(channel); const data = await api(`/api/channels/send-test/${channel}`, { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload), }); if (data?.success) { addProcessEntry('final', `Test message sent via ${channel}.`); } else { alert(`${channel} send-test failed: ${data?.error || 'unknown error'}`); } } catch (err) { alert(`${channel} send-test failed: ${err.message}`); } setButtonBusy(btnId, false, 'Sending...', 'Send Test'); } // ─── Email Account Management (Channel Settings) ───────────────────────── let emailAccountsCache = []; function getCurrentUsername() { if (typeof currentUser === 'object' && currentUser?.username) return currentUser.username; try { return localStorage.getItem(LAST_USER_KEY) || ''; } catch { return ''; } } async function loadEmailAccounts() { const username = getCurrentUsername(); if (!username) { document.getElementById('email-accounts-list').innerHTML = '
사용자 정보를 불러올 수 없습니다.
'; return; } try { const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`); const accounts = data?.accounts || []; emailAccountsCache = accounts; const count = accounts.length; if (count > 0) setChannelStatus('em', 'ok', `${count}개 계정 연결됨`); else setChannelStatus('em', 'off', '미설정'); renderEmailAccountsList(accounts); } catch (err) { document.getElementById('email-accounts-list').innerHTML = '
이메일 계정을 불러올 수 없습니다.
'; } } function renderEmailAccountsList(accounts) { const container = document.getElementById('email-accounts-list'); if (!accounts.length) { container.innerHTML = '
등록된 이메일 계정이 없습니다.
'; return; } container.innerHTML = accounts.map(a => { const esc = s => String(s||'').replace(/
${esc(a.id)} ${esc(a.username)}
IMAP: ${esc(a.imap_host)}:${a.imap_port} | SMTP: ${esc(a.smtp_host)}:${a.smtp_port}
`; }).join(''); } async function saveEmailAccount() { const username = getCurrentUsername(); if (!username) { alert('사용자 정보를 확인할 수 없습니다.'); return; } const id = document.getElementById('em-id').value.trim(); if (!id) { alert('계정 ID를 입력하세요.'); return; } if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { alert('계정 ID는 1-32자의 영문, 숫자, 대시, 언더스코어만 가능합니다.'); return; } const username_field = document.getElementById('em-username').value.trim(); if (!username_field) { alert('이메일 주소를 입력하세요.'); return; } const imap_host = document.getElementById('em-imap-host').value.trim(); if (!imap_host) { alert('IMAP 서버를 입력하세요.'); return; } const smtp_host = document.getElementById('em-smtp-host').value.trim(); if (!smtp_host) { alert('SMTP 서버를 입력하세요.'); return; } const payload = { imap_host, imap_port: Number(document.getElementById('em-imap-port').value) || 993, imap_tls: !!document.getElementById('em-imap-tls').checked, imap_reject_unauthorized: true, smtp_host, smtp_port: Number(document.getElementById('em-smtp-port').value) || 587, smtp_secure: !!document.getElementById('em-smtp-secure').checked, smtp_reject_unauthorized: true, username: username_field, password: document.getElementById('em-password').value || undefined, }; try { const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (data?.success) { addProcessEntry('final', `Email account "${id}" saved.`); document.getElementById('em-password').value = ''; await loadEmailAccounts(); } else { alert('저장 실패: ' + (data?.error || '알 수 없는 오류')); } } catch (err) { alert('저장 실패: ' + err.message); } } async function testEmailAccount(accountId) { const username = getCurrentUsername(); if (!username) return; try { const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST', }); if (data?.success) { addProcessEntry('final', `Email account "${accountId}" IMAP connection OK.`); } else { alert(`테스트 실패: ${data?.error || '알 수 없는 오류'}`); } } catch (err) { alert('테스트 실패: ' + err.message); } } function editEmailAccount(accountId) { const acct = emailAccountsCache.find(a => a.id === accountId); if (!acct) return; document.getElementById('em-id').value = acct.id; document.getElementById('em-username').value = acct.username || ''; document.getElementById('em-imap-host').value = acct.imap_host || ''; document.getElementById('em-imap-port').value = acct.imap_port || 993; document.getElementById('em-smtp-host').value = acct.smtp_host || ''; document.getElementById('em-smtp-port').value = acct.smtp_port || 587; document.getElementById('em-imap-tls').checked = acct.imap_tls !== false; document.getElementById('em-smtp-secure').checked = acct.smtp_secure === true; document.getElementById('em-password').value = ''; document.getElementById('em-password').placeholder = '•••••••• (변경 시에만 입력)'; } async function deleteEmailAccount(accountId) { if (!confirm(`이메일 계정 "${accountId}"를 삭제하시겠습니까?`)) return; const username = getCurrentUsername(); if (!username) return; try { const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE', }); if (data?.success) { addProcessEntry('final', `Email account "${accountId}" deleted.`); await loadEmailAccounts(); } else { alert('삭제 실패: ' + (data?.error || '알 수 없는 오류')); } } catch (err) { alert('삭제 실패: ' + err.message); } } // ─── Heartbeat Notify Toggle ────────────────────────────────────────────── function updateHbNotifyLabel() { const on = document.getElementById('settings-hb-notify')?.checked; const label = document.getElementById('settings-hb-notify-label'); if (label) label.textContent = on ? '알림 받기' : '알림 끄기'; } async function saveHbNotifyConfig() { const notifyEnabled = !!document.getElementById('settings-hb-notify')?.checked; try { const data = await api('/api/heartbeat/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notifyEnabled }), }); if (data?.success) { updateHbNotifyLabel(); addProcessEntry('final', notifyEnabled ? '허트비트 알림 켜짐' : '허트비트 알림 꺼짐'); } } catch {} } 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-llamacpp-endpoint', pc.llama_cpp?.endpoint); v('settings-llamacpp-model', pc.llama_cpp?.model); v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint); v('settings-lmstudio-model', pc.lm_studio?.model); const savedPrimary = pc[prov]?.model || data?.models_primary || ''; if (savedPrimary) { const el = document.getElementById('settings-primary-model'); if (el) el.value = savedPrimary; } _modelSettingsLoaded = true; } catch {} } // Capture primaryModel AFTER _modelSettingsLoaded guard so the DOM value is correct // even when the models tab was never opened in this settings session. const primaryModel = (document.getElementById('settings-primary-model') || {}).value || ''; const modelPayload = { ollama_endpoint: (document.getElementById('settings-ollama-endpoint') || {}).value || 'http://localhost:11434', primary: primaryModel, roles: { manager: primaryModel, executor: primaryModel, verifier: primaryModel, } }; const providerPayload = buildProviderPayload(); // Keep llm.providers.ollama.model in sync with the primary model // so getModelForRole() never falls back to the hardcoded 'qwen3:4b'. if (providerPayload.providers && providerPayload.providers.ollama) { providerPayload.providers.ollama.model = primaryModel || providerPayload.providers.ollama.model || ''; } try { await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ workspace_path, allowed_paths, blocked_paths }) }); await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) }); await api('/api/settings/agent', { method: 'POST', body: JSON.stringify(policyPayload) }); await api('/api/settings/model', { method: 'POST', body: JSON.stringify(modelPayload) }); await api('/api/settings/provider', { method: 'POST', body: JSON.stringify({ llm: providerPayload }) }); const owKey = document.getElementById('cred-ow-key')?.value?.trim(); if (owKey && owKey !== '••••••••') { await api('/api/settings/openweather', { method: 'POST', body: JSON.stringify({ api_key: owKey }) }); } await loadSearchSettingsSummary(); quickSearchRigor = payload.search_rigor || quickSearchRigor; updateQuickModeUI(); addProcessEntry('final', '설정이 저장되었습니다.'); closeSettings(); } catch (err) { addProcessEntry('error', `설정 저장 실패: ${err.message}`); } } // Approve a memory suggestion shown in the process log async function confirmMemory(index) { const entry = processLogEntries[index]; if (!entry || entry.type !== 'memory') return; const suggestion = entry.extra || {}; try { addProcessEntry('info', 'Saving memory...'); const res = await api('/api/memory/confirm', { method: 'POST', body: JSON.stringify(suggestion) }); if (res && res.ok === false) { addProcessEntry('error', `Memory save failed: ${res.error || JSON.stringify(res)}`); return; } processLogEntries[index].type = 'result'; processLogEntries[index].content = `Saved memory: ${String(entry.content).slice(0,200)}`; renderProcessLog(); addProcessEntry('final', 'Memory persisted.'); } catch (err) { addProcessEntry('error', `Failed to save memory: ${err.message}`); } } function rejectMemory(index) { const entry = processLogEntries[index]; if (!entry || entry.type !== 'memory') return; processLogEntries[index].type = 'warn'; processLogEntries[index].content = 'Memory suggestion rejected.'; renderProcessLog(); addProcessEntry('info', 'Memory suggestion rejected by user.'); } // ---- Run mission ---- async function runMission() { const input = document.getElementById('mission-input'); const btn = document.getElementById('run-btn'); const mission = input.value.trim(); if (!mission) return; btn.disabled = true; btn.innerHTML = ' Starting...'; try { const res = await api('/api/jobs', { method: 'POST', body: JSON.stringify({ mission }) }); if (res.error) { log(`Error: ${res.error}`, 'error'); } else { log(`Mission started ? Job ${res.jobId.slice(0, 8)}...`, 'success'); input.value = ''; setTimeout(() => selectJob(res.jobId), 500); } } catch (err) { log(`Failed: ${err.message}`, 'error'); } btn.disabled = false; btn.innerHTML = '? Run'; } // ---- Sessions list ---- function renderSessionsList() { const el = document.getElementById('jobs-list'); if (!chatSessions || chatSessions.length === 0) { el.innerHTML = '
No chats yet.
Start a new conversation.
'; return; } const sorted = [...chatSessions].sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt)); el.innerHTML = sorted.map(s => `
${s.unread ? '🔵 ' : ''}${escHtml(s.title || 'New chat')}
${sessionsEditMode ? `` : ''}
${s.automated ? 'Auto' : ''} ${(s.history || []).length} msgs ${timeAgo(s.updatedAt || s.createdAt)}
`).join(''); } // ---- Select job ---- async function selectJob(jobId) { selectedJobId = jobId; setMode('chat'); renderJobsList(allJobs); await refreshJobDetail(jobId); clearInterval(pollInterval); pollInterval = setInterval(async () => { const job = allJobs.find(j => j.id === jobId); if (job && ['planning', 'executing', 'verifying'].includes(job.status)) { await refreshJobDetail(jobId); } else { clearInterval(pollInterval); } }, 2000); } async function refreshJobDetail(jobId) { try { const data = await api(`/api/jobs/${jobId}`); renderJobDetail(data); } catch {} } function renderJobDetail(data) { const { job, tasks, artifacts, state } = data; const content = document.getElementById('main-content'); const isRunning = ['planning','executing','verifying'].includes(job.status); content.innerHTML = `
${escHtml(job.title)}
${isRunning ? '' : ''}${job.status} ID: ${job.id.slice(0, 12)}... ${timeAgo(job.created_at)}
${state ? `
Mission
${escHtml(state.mission)}
` : ''} ${tasks && tasks.length > 0 ? `
Tasks (${tasks.length})
${tasks.map(t => `
${escHtml(t.title)} ${t.status}
${escHtml(t.description || '')}
${t.acceptance_criteria && t.acceptance_criteria.length > 0 ? `
${t.acceptance_criteria.map(c => `
${escHtml(c)}
`).join('')}
` : ''}
`).join('')}` : `
작업
${isRunning ? ' Planning tasks...' : 'No tasks'}
`} ${artifacts && artifacts.length > 0 ? `
Artifacts (${artifacts.length})
${artifacts.map(a => `
${escHtml(a.path || a.type)} ${a.type}
${a.content ? `
${escHtml(a.content.slice(0, 200))}${a.content.length > 200 ? '...' : ''}
` : ''}
`).join('')}` : ''}
${logLines.map(l => `
${l.text}
`).join('')}
`; const lp = document.getElementById('log-panel'); if (lp) lp.scrollTop = lp.scrollHeight; } // ---- Stats ---- function updateStats(jobs) { const totalEl = document.getElementById('r-total'); const runningEl = document.getElementById('r-running'); const modeEl = document.getElementById('r-completed'); if (totalEl) totalEl.textContent = String(chatSessions.length); if (runningEl) runningEl.textContent = activeChatSessionId ? '1' : '0'; if (modeEl) modeEl.textContent = useAgentMode ? 'On' : 'Off'; } // ---- Approvals ---- async function loadApprovals() { try { const approvals = await api('/api/approvals'); const el = document.getElementById('approvals-list'); if (!approvals || approvals.length === 0) { el.innerHTML = '
대기 중인 승인 없음
'; return; } el.innerHTML = approvals.map(a => `
${escHtml(a.action)}
${escHtml(a.reason || 'Approval required')}
`).join(''); } catch {} } async function resolveApproval(id, decision) { await api(`/api/approvals/${id}`, { method: 'POST', body: JSON.stringify({ decision }) }); loadApprovals(); } function fmtPercent(v, digits = 0) { const n = Number(v); if (!Number.isFinite(n)) return '--%'; return `${n.toFixed(digits)}%`; } function fmtMemoryGb(usedGb, totalGb) { const u = Number(usedGb); const t = Number(totalGb); if (!Number.isFinite(u) || !Number.isFinite(t) || t <= 0) return '-- / -- GB'; return `${u.toFixed(1)} / ${t.toFixed(1)} GB`; } function meterWidth(pct) { const n = Number(pct); if (!Number.isFinite(n)) return '0%'; const bounded = Math.max(0, Math.min(100, n)); return `${bounded}%`; } function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = String(text || ''); } function setMeter(id, pct) { const el = document.getElementById(id); if (!el) return; el.style.width = meterWidth(pct); } let systemStatsFetchInFlight = false; async function loadSystemStats() { if (systemStatsFetchInFlight) return; systemStatsFetchInFlight = true; try { const stats = await api('/api/system-stats'); const cpu = stats?.system?.cpu_percent; const ram = stats?.system?.memory_percent; const gpu = stats?.gpu?.gpu_util_percent; const vram = stats?.gpu?.vram_used_percent; const gpuAvailable = !!stats?.gpu?.available; setText('quick-system-stats', `CPU ${fmtPercent(cpu)} • RAM ${fmtPercent(ram)} • GPU ${gpuAvailable ? fmtPercent(gpu) : '--%'}`); setText('sys-cpu', fmtPercent(cpu)); setText('sys-ram', fmtPercent(ram)); setText('sys-gpu', gpuAvailable ? fmtPercent(gpu) : 'N/A'); setText('sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A'); setMeter('sys-cpu-meter', cpu); setMeter('sys-ram-meter', ram); setMeter('sys-gpu-meter', gpuAvailable ? gpu : 0); setMeter('sys-vram-meter', gpuAvailable ? vram : 0); const ollamaRunning = !!stats?.ollama_process?.running; const ollamaCount = Number(stats?.ollama_process?.process_count || 0); const ollamaMemMb = Number(stats?.ollama_process?.total_memory_mb || 0); const gatewayRss = Number(stats?.gateway_process?.rss_mb || 0); const gpuName = String(stats?.gpu?.name || '').trim(); const memUsage = fmtMemoryGb(stats?.system?.memory_used_gb, stats?.system?.memory_total_gb); const updated = new Date(stats?.timestamp || Date.now()).toLocaleTimeString(); const activeProvider = stats?.active_provider || 'ollama'; const activeModel = stats?.active_model || document.getElementById('current-model')?.textContent || '-'; const isLocalProvider = activeProvider === 'ollama' || activeProvider === 'llama_cpp' || activeProvider === 'lm_studio'; // Show provider + model in the runtime card const providerDisp = (PROVIDER_LABELS[activeProvider] || activeProvider); let runtimeLabel; if (activeProvider === 'ollama') { runtimeLabel = ollamaRunning ? `Ollama • ${activeModel}` : `Ollama offline • ${activeModel}`; } else { runtimeLabel = `${providerDisp} • ${activeModel}`; } setText('sys-ollama', runtimeLabel); const memPart = isLocalProvider && ollamaMemMb > 0 ? ` • LLM ${ollamaMemMb.toFixed(0)} MB` : ''; setText( 'sys-runtime-sub', `${memUsage}${memPart} • Gateway ${gatewayRss.toFixed(0)} MB${gpuName ? ` • ${gpuName}` : ''} • ${updated}` ); const onlineUsers = Array.isArray(stats?.online_users) ? stats.online_users : []; const onlineEl = document.getElementById('sys-online-users'); if (onlineEl) { if (onlineUsers.length === 0) { onlineEl.textContent = '없음'; onlineEl.style.color = 'var(--muted)'; } else { onlineEl.innerHTML = onlineUsers.map(u => `${escHtml(String(u))}` ).join(''); onlineEl.style.color = 'var(--text)'; } } } catch { setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%'); setText('sys-cpu', '--%'); setText('sys-ram', '--%'); setText('sys-gpu', 'N/A'); setText('sys-vram', 'N/A'); setText('sys-ollama', 'Stats unavailable'); setText('sys-runtime-sub', 'Could not fetch system stats right now'); setMeter('sys-cpu-meter', 0); setMeter('sys-ram-meter', 0); setMeter('sys-gpu-meter', 0); setMeter('sys-vram-meter', 0); } finally { systemStatsFetchInFlight = false; } } // ---- Status ---- const PROVIDER_LABELS = { ollama: 'Ollama', llama_cpp: 'llama.cpp', lm_studio: 'LM Studio', openai: 'OpenAI', openai_codex: 'ChatGPT' }; 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; dot.className = 'dot ' + (backendOnline ? 'online' : 'offline'); document.getElementById('ollama-status').textContent = backendOnline ? `${providerLabel} Online` : `${providerLabel} Offline`; const ro = document.getElementById('r-ollama'); if (ro) { ro.textContent = backendOnline ? 'Online' : 'Offline'; ro.className = 'info-val ' + (backendOnline ? 'green' : 'red'); } 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; } 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(); } // ---- Log ---- const logLines = []; function log(text, type = 'log') { const ts = new Date().toLocaleTimeString(); logLines.push({ text: `[${ts}] ${text}`, type }); if (logLines.length > 100) logLines.shift(); const lp = document.getElementById('log-panel'); if (lp) { lp.innerHTML = logLines.map(l => `
${l.text}
`).join(''); lp.scrollTop = lp.scrollHeight; } } // ---- Helpers ---- function escHtml(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"'); } function _normalizeFileUrl(url) { // Convert sandbox:/mnt/... or /mnt/user-data/... paths to /api/files/... URLs if (/^sandbox:/i.test(url)) { var p = url.replace(/^sandbox:\/*/i, ''); var idx = p.indexOf('uploads/'); return '/api/files/' + (idx !== -1 ? p.slice(idx) : 'uploads/' + p.split('/').pop()); } if (/^\/mnt\/(user-data|data)\//i.test(url)) { var idx = url.indexOf('uploads/'); return '/api/files/' + (idx !== -1 ? url.slice(idx) : 'uploads/' + url.split('/').pop()); } return url; } function _renderLinkButton(text, url, opts) { var hideDownload = opts && opts.hideDownload; url = _normalizeFileUrl(url); var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3']; var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); }) || fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; }); var decodedUrl = decodeURIComponent(url).toLowerCase(); var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx'); var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf'); var isXlsx = /\.(xlsx|xls)(\?.*)?$/i.test(decodedUrl) || /\.(xlsx|xls)$/i.test(text.toLowerCase()); if (isPptx) { var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9); return '
' + '' + '⬇ ' + text + '' + ' ' + '' + '
'; } if (isPdf) { var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9); return '
' + '
' + '' + '📄 ' + text + '' + '' + (hideDownload ? '' : '⬇ 저장') + '
' + '' + '
'; } if (isXlsx) { var xlsxSafeUrl = escHtml(url); return '
' + '
' + '📊' + '' + escHtml(text) + '' + '' + '' + '⬇ 다운로드' + '
' + '' + '
'; } var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase()); if (isTxt) { var txtLabel = escHtml(text.replace(/^📝\s*/, '')); return '
' + '
' + '📝' + '' + txtLabel + '' + '' + '⬇ 저장' + '
' + '' + '
'; } if (isDownload) { return '' + '⬇ ' + text + ''; } return '' + text + ''; } function togglePdfEmbed(url, containerId, btn) { var container = document.getElementById(containerId); if (!container) return; var iframe = container.querySelector('iframe'); if (container.style.display === 'none') { container.style.display = 'block'; if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정 btn.innerHTML = '▲ 닫기'; } else { container.style.display = 'none'; btn.innerHTML = '◀ 미리보기'; } } var _luckyCurrentSaveUrl = null; function openXlsxNewWindow(url, title) { var q = '/xlsx-viewer.html?file=' + encodeURIComponent(url) + '&save=' + encodeURIComponent(url) + '&title=' + encodeURIComponent(title || '스프레드시트'); window.open(q, '_blank', 'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes'); } function toggleXlsxEmbedBtn(btn) { var url = encodeURI(btn.getAttribute('data-xlsx-url')); var card = btn.closest('.xlsx-card'); var nameEl = card ? card.querySelector('span[style*="font-weight"]') : null; var title = nameEl ? nameEl.textContent : url.split('/').pop(); var saveUrl = card ? card.querySelector('[data-xlsx-save-url]')?.getAttribute('data-xlsx-save-url') : null; openLuckyModal(url, title, saveUrl); } function openLuckyModal(url, title, saveUrl) { var modal = document.getElementById('lucky-modal'); var titleEl = document.getElementById('lucky-modal-title'); var container = document.getElementById('lucky-container'); if (!modal || !container) return; // Reset box to centered state on each open var box = document.getElementById('lucky-modal-box'); if (box) { box.style.position = 'relative'; box.style.left = ''; box.style.top = ''; box.style.width = '96vw'; box.style.height = '92vh'; box._isAbsolute = false; box.dataset.minimized = '0'; } modal.style.display = 'flex'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; titleEl.textContent = title || '스프레드시트'; _luckyCurrentSaveUrl = saveUrl || null; container.innerHTML = '
Luckysheet 로딩 중… (최초 1회 ~3MB)
'; initLuckyModalInteraction(); fetch(url, { headers: authHeaders() }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(function(buf) { loadLuckysheet(function() { if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') { container.innerHTML = '
Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.
'; return; } var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); LuckyExcel.transformExcelToLucky(file, function(exportInfo) { container.innerHTML = ''; try { luckysheet.destroy(); } catch(e) {} luckysheet.create({ container: 'lucky-container', data: exportInfo.sheets, title: '', lang: 'en', showinfobar: false, showstatisticBar: false, sheetBottomConfig: true, allowUpdate: true, enableAddRow: true, enableAddCol: true, showToolbar: true, plugins: [], defaultFontSize: 11, defaultFontName: 'Nanum Gothic', }); }, function(err) { container.innerHTML = '
Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '
'; }); }); }) .catch(function(e) { container.innerHTML = '
불러오기 실패: ' + escHtml(e.message) + '
'; }); } function minimizeLuckyModal() { var box = document.getElementById('lucky-modal-box'); var modal = document.getElementById('lucky-modal'); var btn = event.target; if (!box || !modal) return; var minimized = box.dataset.minimized === '1'; if (minimized) { box.style.height = box._savedHeight || '92vh'; box.dataset.minimized = '0'; btn.textContent = '–'; btn.title = '최소화'; if (!box._isAbsolute) { modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; } } else { box._savedHeight = box.style.height || '92vh'; box.dataset.minimized = '1'; btn.innerHTML = '◻'; btn.title = '복원'; if (box._isAbsolute) { box.style.top = (window.innerHeight - 52) + 'px'; box.style.height = 'auto'; } else { box.style.height = 'auto'; modal.style.alignItems = 'flex-end'; modal.style.justifyContent = 'center'; modal.style.paddingBottom = '0'; } } } function closeLuckyModal() { var modal = document.getElementById('lucky-modal'); var box = document.getElementById('lucky-modal-box'); if (modal) { modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; } if (box) { box.style.position = 'relative'; box.style.left = ''; box.style.top = ''; box.style.width = '96vw'; box.style.height = '92vh'; box._isAbsolute = false; box._savedHeight = null; box.dataset.minimized = '0'; } try { luckysheet.destroy(); } catch(e) {} _luckyCurrentSaveUrl = null; } // Close on backdrop click document.getElementById('lucky-modal')?.addEventListener('click', function(e) { if (e.target === this) closeLuckyModal(); }); function initLuckyModalInteraction() { var box = document.getElementById('lucky-modal-box'); var modal = document.getElementById('lucky-modal'); if (!box || box._interactInit) return; box._interactInit = true; // Inject 8 resize handles [ { dir:'n', css:'top:0;left:10px;right:10px;height:6px;cursor:n-resize' }, { dir:'s', css:'bottom:0;left:10px;right:10px;height:6px;cursor:s-resize' }, { dir:'e', css:'right:0;top:10px;bottom:10px;width:6px;cursor:e-resize' }, { dir:'w', css:'left:0;top:10px;bottom:10px;width:6px;cursor:w-resize' }, { dir:'ne', css:'top:0;right:0;width:12px;height:12px;cursor:ne-resize' }, { dir:'nw', css:'top:0;left:0;width:12px;height:12px;cursor:nw-resize' }, { dir:'se', css:'bottom:0;right:0;width:12px;height:12px;cursor:se-resize' }, { dir:'sw', css:'bottom:0;left:0;width:12px;height:12px;cursor:sw-resize' }, ].forEach(function(d) { var h = document.createElement('div'); h.className = 'lucky-rh'; h.dataset.dir = d.dir; h.style.cssText = 'position:absolute;z-index:200;' + d.css; box.appendChild(h); }); function toAbsolute() { if (box._isAbsolute) return; var rect = box.getBoundingClientRect(); modal.style.display = 'block'; box.style.position = 'absolute'; box.style.left = rect.left + 'px'; box.style.top = rect.top + 'px'; box.style.width = rect.width + 'px'; box.style.height = rect.height + 'px'; box._isAbsolute = true; } // Title bar drag var header = box.firstElementChild; if (header) { header.style.cursor = 'grab'; header.addEventListener('mousedown', function(e) { if (e.target.tagName === 'BUTTON') return; toAbsolute(); var rect = box.getBoundingClientRect(); var ox = e.clientX - rect.left, oy = e.clientY - rect.top; header.style.cursor = 'grabbing'; function onMove(e) { box.style.left = (e.clientX - ox) + 'px'; box.style.top = Math.max(0, e.clientY - oy) + 'px'; } function onUp() { header.style.cursor = 'grab'; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); }); } // Resize handle drag box.addEventListener('mousedown', function(e) { var handle = e.target.closest ? e.target.closest('.lucky-rh') : null; if (!handle) return; toAbsolute(); var dir = handle.dataset.dir; var rect = box.getBoundingClientRect(); var sx = e.clientX, sy = e.clientY; var sl = rect.left, st = rect.top, sw = rect.width, sh = rect.height; document.body.style.userSelect = 'none'; document.body.style.cursor = handle.style.cursor; function onMove(e) { var dx = e.clientX - sx, dy = e.clientY - sy; var nl = sl, nt = st, nw = sw, nh = sh; if (dir.includes('e')) nw = Math.max(380, sw + dx); if (dir.includes('s')) nh = Math.max(180, sh + dy); if (dir.includes('w')) { nw = Math.max(380, sw - dx); nl = sl + sw - nw; } if (dir.includes('n')) { nh = Math.max(180, sh - dy); nt = st + sh - nh; } box.style.left = nl + 'px'; box.style.top = nt + 'px'; box.style.width = nw + 'px'; box.style.height = nh + 'px'; } function onUp() { document.body.style.userSelect = ''; document.body.style.cursor = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); e.stopPropagation(); }); } // ── Voice: STT (Web Speech API — Chrome 내장 Google STT) ──────────────── var _speechRecog = null; var _isRecording = false; var _micAutosend = localStorage.getItem('micAutosend') !== 'false'; // default on function updateMicAutosend() { _micAutosend = document.getElementById('mic-autosend-toggle').checked; localStorage.setItem('micAutosend', String(_micAutosend)); } function initMicAutosendToggle() { var toggle = document.getElementById('mic-autosend-toggle'); if (toggle) toggle.checked = _micAutosend; } function toggleMicRecording() { if (_isRecording) stopMicRecording(); else startMicRecording(); } function startMicRecording() { var SpeechRecog = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecog) { alert('이 브라우저는 음성 인식을 지원하지 않습니다.\nChrome을 사용해주세요.'); return; } _speechRecog = new SpeechRecog(); _speechRecog.lang = 'ko-KR'; _speechRecog.interimResults = false; _speechRecog.maxAlternatives = 1; _speechRecog.continuous = false; var btn = document.getElementById('mic-btn'); _speechRecog.onstart = function() { _isRecording = true; if (btn) { btn.textContent = '⏹'; btn.title = '말하는 중… (클릭 시 중지)'; btn.classList.add('recording'); } }; _speechRecog.onresult = function(e) { var transcript = e.results[0][0].transcript; var input = document.getElementById('chat-input'); if (input && transcript) { input.value = (input.value ? input.value + ' ' : '') + transcript; input.focus(); input.dispatchEvent(new Event('input')); if (_micAutosend) setTimeout(function() { handleSendStop(); }, 50); } }; _speechRecog.onerror = function(e) { if (e.error !== 'no-speech') alert('음성 인식 오류: ' + e.error); }; _speechRecog.onend = function() { _isRecording = false; _speechRecog = null; if (btn) { btn.textContent = '🎤'; btn.title = '음성 입력'; btn.classList.remove('recording'); } }; _speechRecog.start(); } function stopMicRecording() { if (_speechRecog) { _speechRecog.stop(); } } // ── Voice: TTS (음성 출력) ─────────────────────────────────────────────── var _ttsEnabled = localStorage.getItem('ttsEnabled') !== 'false'; // default on var _ttsAudio = null; var _ttsBtnActive = null; function updateTTSMode() { _ttsEnabled = document.getElementById('tts-mode-toggle').checked; localStorage.setItem('ttsEnabled', String(_ttsEnabled)); document.body.classList.toggle('tts-disabled', !_ttsEnabled); if (!_ttsEnabled && _ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); _ttsBtnActive = null; } } } function initTTSToggle() { var toggle = document.getElementById('tts-mode-toggle'); if (toggle) toggle.checked = _ttsEnabled; document.body.classList.toggle('tts-disabled', !_ttsEnabled); } function playTTSMsg(btn, idx) { var msg = chatHistory[idx]; if (!msg) return; var text = (msg.content || '').replace(/!\[.*?\]\(.*?\)/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/```[\s\S]*?```/g, '').replace(/[#*`_~>]/g, ' ').replace(/\s+/g, ' ').trim(); if (!text) return; // Toggle off if same button is playing if (_ttsBtnActive === btn && _ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; return; } // Stop previous if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; } if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); } btn.textContent = '⌛'; btn.classList.add('playing'); _ttsBtnActive = btn; fetch('/api/voice/tts', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), credentials: 'include', body: JSON.stringify({ text: text.slice(0, 2000) }), }) .then(function(r) { if (!r.ok) throw new Error('TTS 오류 ' + r.status); return r.blob(); }) .then(function(blob) { var url = URL.createObjectURL(blob); _ttsAudio = new Audio(url); _ttsAudio.onended = function() { URL.revokeObjectURL(url); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; }; _ttsAudio.onerror = function() { URL.revokeObjectURL(url); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; }; btn.textContent = '⏹'; _ttsAudio.play(); }) .catch(function(e) { btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; alert('TTS 오류: ' + e.message); }); } function saveLuckyModal() { var url = _luckyCurrentSaveUrl; if (!url) { alert('저장할 파일 경로가 없습니다.'); return; } var btn = document.getElementById('lucky-modal-save'); var relPath = url.replace(/^\/api\/files\//, ''); loadSheetJS(function() { try { // Luckysheet → SheetJS aoa conversion var sheets = luckysheet.getAllSheets(); var wb = XLSX.utils.book_new(); sheets.forEach(function(sheet) { var celldata = sheet.celldata || []; if (!celldata.length) { XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([[]]), sheet.name || 'Sheet'); return; } var maxR = 0, maxC = 0; celldata.forEach(function(c) { maxR = Math.max(maxR, c.r); maxC = Math.max(maxC, c.c); }); var aoa = []; for (var r = 0; r <= maxR; r++) aoa.push(new Array(maxC + 1).fill('')); celldata.forEach(function(c) { if (c.v == null) return; var val = (typeof c.v === 'object') ? (c.v.m !== undefined ? c.v.m : (c.v.v !== undefined ? c.v.v : '')) : c.v; aoa[c.r][c.c] = val; }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet'); }); var xlsxBuf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); btn.disabled = true; btn.textContent = '저장 중…'; fetch('/api/files/' + relPath, { method: 'PUT', headers: authHeaders(), body: new Blob([xlsxBuf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), }) .then(function(r) { return r.json(); }) .then(function(res) { btn.disabled = false; btn.innerHTML = '💾 저장'; if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '💾 저장'; }, 2000); } else alert('저장 실패: ' + (res.error || '알 수 없는 오류')); }) .catch(function(e) { btn.disabled = false; btn.innerHTML = '💾 저장'; alert('저장 오류: ' + e.message); }); } catch(e) { alert('Excel 변환 오류: ' + e.message); } }); } function toggleStagedExcelPreview(id) { var el = document.getElementById('excel-staged-' + id); if (!el) return; el.style.display = el.style.display === 'none' ? 'block' : 'none'; } function toggleTxtEmbedBtn(btn) { var card = btn.closest('.txt-card'); if (!card) return; var container = card.querySelector('.txt-preview-body'); if (!container) return; var url = encodeURI(btn.getAttribute('data-txt-url')); if (container.style.display === 'none') { container.style.display = 'block'; btn.innerHTML = '▲ 접기'; if (!container.dataset.loaded) { container.textContent = '불러오는 중…'; fetch(url, { headers: authHeaders() }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }) .then(function(t) { container.textContent = t; container.dataset.loaded = '1'; }) .catch(function(e) { container.textContent = '텍스트를 불러올 수 없습니다. (' + e.message + ')'; }); } } else { container.style.display = 'none'; btn.innerHTML = '◀ 미리보기'; } } function windyToEmbedUrl(url) { try { var u = new URL(url); // Already an embed URL — use as-is if (u.hostname === 'embed.windy.com') return url; // windy.com map URL → convert to embed if (/windy\.com/.test(u.hostname)) { var lat = u.searchParams.get('lat') || ''; var lon = u.searchParams.get('lon') || ''; var zoom = u.searchParams.get('zoom') || ''; var overlay = u.searchParams.get('overlay') || ''; var level = u.searchParams.get('level') || 'surface'; // Handle windy.com comma format: ?overlay,lat,lon,zoom or pathname /-overlay?overlay,lat,lon,zoom if (!lat || !lon) { var qs = u.search.slice(1); // e.g. 'radar,36.545,127.974,7' var parts = qs.split(','); if (parts.length >= 3) { overlay = overlay || parts[0]; lat = parts[1]; lon = parts[2]; zoom = zoom || parts[3] || '7'; } } lat = lat || '37.5'; lon = lon || '126.9'; zoom = zoom || '7'; overlay = overlay || 'rain'; // Map radar overlay name if (overlay === '-radar' || overlay === 'radar') overlay = 'radar'; return 'https://embed.windy.com/embed2.html?lat=' + lat + '&lon=' + lon + '&zoom=' + zoom + '&level=' + level + '&overlay=' + overlay + '&menu=&message=true&marker=&calendar=now&pressure=&type=map' + '&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C'; } return null; } catch(e) { return null; } } function youtubeToEmbedUrl(url) { try { var u = new URL(url); var id = null; if (u.hostname === 'youtu.be') { id = u.pathname.slice(1).split(/[/?]/)[0]; } else if (/youtube\.com/.test(u.hostname)) { id = u.searchParams.get('v'); if (!id) { var m = u.pathname.match(/\/(?:shorts|embed)\/([^/?]+)/); if (m) id = m[1]; } } if (!id) return null; var start = u.searchParams.get('t') || u.searchParams.get('start'); return 'https://www.youtube.com/embed/' + id + (start ? '?start=' + parseInt(start, 10) : ''); } catch(e) { return null; } } function mapsToEmbedUrl(url) { try { var u = new URL(url); var q = null; var placeMatch = u.pathname.match(/\/maps\/place\/([^/@?]+)/); if (placeMatch) q = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); if (!q) { var searchMatch = u.pathname.match(/\/maps\/search\/([^/@?]+)/); if (searchMatch) q = decodeURIComponent(searchMatch[1].replace(/\+/g, ' ')); } if (!q) { var coordMatch = u.pathname.match(/\/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); if (coordMatch) q = coordMatch[1] + ',' + coordMatch[2]; } if (!q) q = u.searchParams.get('q') || u.searchParams.get('query'); if (!q) return null; return 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&output=embed&hl=ko'; } catch(e) { return null; } } function renderMarkdown(text, opts) { if (!text) return ''; // Extract existing tags before escaping so they survive escHtml() var imgPlaceholders = []; var imgRe = /]*class=["']md-img["'][^>]*>/gi; var textSafe = text.replace(imgRe, function(m) { var idx = imgPlaceholders.length; imgPlaceholders.push(m); return '\x00IMG' + idx + '\x00'; }); var html = escHtml(textSafe); // Restore tags (already safe HTML) imgPlaceholders.forEach(function(img, i) { html = html.replace('\x00IMG' + i + '\x00', img); }); // Code blocks (``` ... ```) html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) { var trimmed = code.trim(); if (lang === 'midi-widget') { try { var mwRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'"); var mwData = JSON.parse(mwRaw); var mwFilename = mwData.filename || (mwData.saved_to ? mwData.saved_to.split('/').pop() : 'score.mid'); return buildMidiPickerWidget(mwData, mwFilename).html; } catch(e) { return '
' + escHtml(trimmed) + '
'; } } if (lang === 'abc' || lang === 'lilypond') { var encoded = encodeURIComponent(trimmed); var widgetId = 'music-' + Math.random().toString(36).slice(2, 9); return '
' + '
' + '🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' + escHtml(trimmed) + '
' + '' + '
' + '
'; } if (lang === 'tradingview') { var tvSymbol = '', tvInterval = 'D'; // code content is already HTML-escaped at this point — unescape before parsing var tvRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'").trim(); if (tvRaw.startsWith('{')) { try { var tvJson = JSON.parse(tvRaw); tvSymbol = (tvJson.symbol || tvJson.SYMBOL || '').toString().toUpperCase(); tvInterval = (tvJson.interval || tvJson.INTERVAL || 'D').toString().toUpperCase(); } catch(e) { tvSymbol = tvRaw.split('|')[0].trim().toUpperCase(); } } else { var parts = tvRaw.split('|'); tvSymbol = (parts[0] || '').trim().toUpperCase(); tvInterval = (parts[1] || 'D').trim().toUpperCase(); } if (!tvSymbol) return '
' + trimmed + '
'; var tvId = 'tv-' + Math.random().toString(36).slice(2, 9); return '
' + '
' + '📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
'; } if (lang === 'kstock') { var ksRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').trim(); var ksParts = ksRaw.split('|'); var ksCode = ksParts[0].trim().replace(/\D/g,''); // 숫자만 (005930) var ksPeriod = (ksParts[1] || 'day').trim().toLowerCase(); var ksPeriodMap = { '1d':'day','day':'day','1w':'week','week':'week','1m':'month','month':'month','3m':'3month','3month':'3month','1y':'year','year':'year','3y':'3year','3year':'3year' }; var ksNaverPeriod = ksPeriodMap[ksPeriod] || 'day'; var ksPeriodLabel = { day:'일봉', week:'주봉', month:'월봉', '3month':'3개월', year:'1년', '3year':'3년' }[ksNaverPeriod] || ksNaverPeriod; if (!ksCode) return '
' + trimmed + '
'; var ksId = 'ks-' + Math.random().toString(36).slice(2, 9); var ksImgUrl = 'https://ssl.pstatic.net/imgfinance/chart/item/area/' + ksNaverPeriod + '/' + ksCode + '.png'; var ksNaverUrl = 'https://finance.naver.com/item/main.naver?code=' + ksCode; return '
' + '
' + '📈 ' + escHtml(ksCode) + ' · ' + escHtml(ksPeriodLabel) + ' · 네이버금융' + '
' + '네이버금융 ↗' + '' + '' + '
' + '
' + '
' + '' + escHtml(ksCode) + ' 차트차트를 불러올 수 없습니다.\'">' + '
' + '
'; } return '
' + trimmed + '
'; }); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Images: ![alt](url) html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) { var safeAlt = alt.replace(/"/g, '"'); var errAlt = (alt || '이미지').replace(/'/g, ''').replace(/')"; return '' + safeAlt + ''; }); // Links: [text](url) — render download links for file types as buttons, PPTX gets preview html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) { return _renderLinkButton(text, url, opts); }); // Bare /api/files/... URLs — auto-convert to download buttons even without markdown syntax html = html.replace(/(\s|^)(\/api\/files\/[^\s<"]+)/g, function(_, space, url) { return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url, opts); }); // YouTube URLs on their own line → inline iframe html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:(?:www\.|m\.)?youtube\.com\/(?:watch|shorts|embed)|youtu\.be\/)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) { var embedUrl = youtubeToEmbedUrl(url); if (!embedUrl) return m; var safeEmbed = embedUrl.replace(/"/g, '"'); var safeOrig = url.replace(/"/g, '"'); return pre + '' + post; }); // Google Maps URLs on their own line → inline iframe html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:www\.google\.com\/maps|maps\.google\.com|goo\.gl\/maps)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) { var embedUrl = mapsToEmbedUrl(url); if (!embedUrl) return m; var safeEmbed = embedUrl.replace(/"/g, '"'); var safeOrig = url.replace(/"/g, '"'); return pre + '' + post; }); // Windy URLs in (model used ![alt](url) syntax) → inline iframe [must run first] html = html.replace(/]*src="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*\/?>/gi, function(m, url) { var embedUrl = windyToEmbedUrl(url); if (!embedUrl) return m; var safeEmbed = embedUrl.replace(/"/g, '"'); var safeOrig = url.replace(/"/g, '"'); return ''; }); // Windy URLs inside anchor tags (markdown links) → inline iframe [must run BEFORE bare-URL regex] html = html.replace(/]*href="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*>[^<]*<\/a>/gi, function(m, url) { var embedUrl = windyToEmbedUrl(url); if (!embedUrl) return m; var safeEmbed = embedUrl.replace(/"/g, '"'); var safeOrig = url.replace(/"/g, '"'); return ''; }); // Windy URLs on their own line → inline iframe [runs after anchor regex to avoid double-render] html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) { var embedUrl = windyToEmbedUrl(url); if (!embedUrl) return m; var safeEmbed = embedUrl.replace(/"/g, '"'); var safeOrig = url.replace(/"/g, '"'); return pre + '' + post; }); // Bare image URLs on their own line → inline image html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/[^\s<>"()\n#?]+\.(?:jpe?g|png|gif|webp|svg|bmp|avif|tiff)(?:[?#][^\s<>"()\n]*)?)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) { var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','✖ 이미지를 불러올 수 없습니다.')"; return pre + '' + post; }); // Bold html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); // Italic html = html.replace(/(?$1'); // Markdown tables → HTML (line-by-line, O(n), must run BEFORE \n →
) (function() { var lines = html.split('\n'); var out = []; var parseRow = function(line) { var cells = line.trim().split('|'); if (cells.length && cells[0].trim() === '') cells.shift(); if (cells.length && cells[cells.length - 1].trim() === '') cells.pop(); return cells.map(function(c) { return c.trim(); }); }; var isSep = function(line) { return /^\|[\s|:\-]+\|[ \t]*$/.test(line.trim()); }; var isRow = function(line) { return /^\|.+\|[ \t]*$/.test(line.trim()); }; var i = 0; while (i < lines.length) { var line = lines[i]; if (isRow(line) && i + 1 < lines.length && isSep(lines[i + 1])) { var tbl = [line, lines[i + 1]]; var j = i + 2; while (j < lines.length && isRow(lines[j])) { tbl.push(lines[j]); j++; } var headers = parseRow(tbl[0]); var thead = '' + headers.map(function(h) { return '' + h + ''; }).join('') + ''; var tbody = '' + tbl.slice(2).map(function(row) { return '' + parseRow(row).map(function(c) { return '' + c + ''; }).join('') + ''; }).join('') + ''; out.push('
' + thead + tbody + '
'); i = j; continue; } out.push(line); i++; } html = out.join('\n'); })(); // Line breaks html = html.replace(/\n/g, '
'); return html; } function renderTVChart(btn, containerId, symbol, interval) { btn.disabled = true; btn.textContent = '로딩 중...'; var isDark = document.documentElement.getAttribute('data-theme') !== 'light'; function doInit() { try { new TradingView.widget({ container_id: containerId, autosize: true, symbol: symbol, interval: interval || 'D', timezone: 'Asia/Seoul', theme: isDark ? 'dark' : 'light', style: '1', locale: 'kr', enable_publishing: false, allow_symbol_change: true, save_image: false, withdateranges: true, hide_side_toolbar: false, studies: ['RSI@tv-basicstudies', 'MACD@tv-basicstudies'], }); btn.style.display = 'none'; } catch(e) { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; } } if (window.TradingView && window.TradingView.widget) { doInit(); } else { var s = document.createElement('script'); s.src = 'https://s3.tradingview.com/tv.js'; s.onload = doInit; s.onerror = function() { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; }; document.head.appendChild(s); } } function toggleMusicWidget(widgetId) { var widget = document.getElementById(widgetId); if (!widget) return; // widgetId may point to either .music-widget or .music-widget-body var body = widget.classList.contains('music-widget-body') ? widget : widget.querySelector('.music-widget-body'); var btn = widget.classList.contains('music-widget-body') ? widget.closest('.music-widget')?.querySelector('[title="접기/펼치기"]') : widget.querySelector('[title="접기/펼치기"]'); if (!body) return; var collapsed = body.classList.toggle('collapsed'); if (btn) btn.textContent = collapsed ? '▸' : '▾'; } function closeMusicWidget(widgetId) { var el = document.getElementById(widgetId); if (!el) return; var widget = el.closest('.music-widget') || el; widget.remove(); } // ── Web MIDI router ────────────────────────────────────────────────────────── const midiRouter = { access: null, timers: [], isPlaying: false, stop() { this.timers.forEach(t => clearTimeout(t)); this.timers = []; this.isPlaying = false; if (this.access) { this.access.outputs.forEach(out => { for (let ch = 0; ch < 16; ch++) { try { out.send([0xB0 | ch, 123, 0]); } catch {} // All Notes Off try { out.send([0xB0 | ch, 121, 0]); } catch {} // Reset All Controllers } }); } }, // Parse raw MIDI bytes → scheduled events list // Returns [{timeMs, msg:[...bytes]}, ...] _parseMidiRaw(buf) { const u8 = new Uint8Array(buf); const dv = new DataView(buf); if (dv.getUint32(0) !== 0x4D546864) return []; // not MThd const tpq = dv.getUint16(12); const ntrk = dv.getUint16(10); let tempo = 500000; const events = []; const readVarLen = (pos) => { let val = 0, b; do { b = u8[pos++]; val = (val << 7) | (b & 0x7F); } while (b & 0x80); return { val, pos }; }; const ticksToMs = (ticks) => (ticks / tpq) * (tempo / 1000); let p = 14; for (let t = 0; t < ntrk; t++) { if (p + 8 > u8.length || dv.getUint32(p) !== 0x4D54726B) break; const tlen = dv.getUint32(p + 4); p += 8; const end = p + tlen; let tick = 0, rs = 0; while (p < end) { const dl = readVarLen(p); p = dl.pos; tick += dl.val; let b = u8[p]; if (b & 0x80) { rs = b; p++; } else { b = rs; } const typ = b & 0xF0, ch = b & 0x0F; const ms = ticksToMs(tick); if (typ === 0x90 || typ === 0x80) { const note = u8[p++], vel = u8[p++]; events.push({ ms, msg: [typ | ch, note, vel] }); } else if (typ === 0xA0 || typ === 0xB0 || typ === 0xE0) { events.push({ ms, msg: [b, u8[p], u8[p+1]] }); p += 2; } else if (typ === 0xC0) { events.push({ ms, msg: [0xC0 | ch, u8[p++]] }); } else if (typ === 0xD0) { events.push({ ms, msg: [b, u8[p++]] }); } else if (b === 0xFF) { const mt = u8[p++]; const ll = readVarLen(p); p = ll.pos; if (mt === 0x51 && ll.val >= 3) { // Tempo change: recalculate from current tick tempo = (u8[p] << 16) | (u8[p+1] << 8) | u8[p+2]; } p += ll.val; } else if (b === 0xF0 || b === 0xF7) { const ll = readVarLen(p); p = ll.pos + ll.val; } else { p++; } } p = end; } events.sort((a, b) => a.ms - b.ms); return events; }, async play(midiUrl, outputId, onStatus) { this.stop(); const output = this.access.outputs.get(outputId); if (!output) throw new Error('기기를 찾을 수 없습니다'); if (onStatus) onStatus('로딩 중...'); const resp = await fetch(midiUrl); const buf = await resp.arrayBuffer(); // Parse raw MIDI for exact Program Change + all events const events = this._parseMidiRaw(buf); if (!events.length) throw new Error('MIDI 파싱 실패'); // GM Reset SysEx → 신디를 GM 모드로 초기화 try { output.send(new Uint8Array([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7])); } catch {} // Send all PC + Bank Select (CC#0=0, CC#32=0) events upfront const initPc = new Set(); events.forEach(ev => { if ((ev.msg[0] & 0xF0) === 0xC0 && !initPc.has(ev.msg[0] & 0x0F)) { initPc.add(ev.msg[0] & 0x0F); const ch = ev.msg[0] & 0x0F; try { output.send(new Uint8Array([0xB0 | ch, 0, 0])); } catch {} // Bank Select MSB=0 try { output.send(new Uint8Array([0xB0 | ch, 32, 0])); } catch {} // Bank Select LSB=0 try { output.send(new Uint8Array(ev.msg)); } catch {} } }); const NOTE_DELAY = 200; // ms — give synth time to process GM reset + PCs const totalMs = events[events.length - 1].ms + NOTE_DELAY + 500; this.isPlaying = true; if (onStatus) onStatus('재생 중...'); events.forEach(ev => { this.timers.push(setTimeout(() => { if (!this.isPlaying) return; try { output.send(new Uint8Array(ev.msg)); } catch {} }, ev.ms + NOTE_DELAY)); }); this.timers.push(setTimeout(() => { this.isPlaying = false; if (onStatus) onStatus('완료'); }, totalMs)); } }; async function midiRouterToggle(routerId, btnId) { const panel = document.getElementById(routerId); if (!panel) { alert('패널을 찾을 수 없습니다: ' + routerId); return; } const isHidden = panel.style.display === 'none' || panel.style.display === ''; if (isHidden) { panel.style.display = 'flex'; panel.style.flexWrap = 'wrap'; panel.style.gap = '6px'; panel.style.alignItems = 'center'; const sel = document.getElementById(routerId + '-sel'); const st = document.getElementById(routerId + '-st'); try { if (!navigator.requestMIDIAccess) throw new Error('Web MIDI 미지원 — Chrome/Edge 필요'); if (!midiRouter.access) { midiRouter.access = await navigator.requestMIDIAccess(); } if (sel) { sel.innerHTML = ''; midiRouter.access.outputs.forEach((out, id) => { const opt = document.createElement('option'); opt.value = id; opt.textContent = out.name; sel.appendChild(opt); }); if (sel.options.length <= 1 && st) st.textContent = 'MIDI 출력 기기 없음'; } } catch (e) { if (st) st.textContent = '오류: ' + e.message; } } else { panel.style.display = 'none'; midiRouter.stop(); } } async function midiRouterPlay(routerId, midiUrl) { const sel = document.getElementById(routerId + '-sel'); const st = document.getElementById(routerId + '-st'); const outputId = sel ? sel.value : ''; if (!outputId) { if (st) st.textContent = '기기를 선택하세요'; return; } try { await midiRouter.play(midiUrl, outputId, msg => { if (st) st.textContent = msg; }); } catch (e) { if (st) st.textContent = '오류: ' + e.message; } } function midiRouterStop(routerId) { midiRouter.stop(); const st = document.getElementById(routerId + '-st'); if (st) st.textContent = '정지'; } function buildMusicActions(data, downloadName) { var midiUrl = data.midi_url; var pdfUrl = data.pdf_url; var rid = 'mr-' + Math.random().toString(36).slice(2, 10); var html = '
'; if (data.saved_to) { html += '
💾 ' + escHtml(data.saved_to) + '
'; } if (midiUrl) { html += ''; html += ''; html += ''; html += '⬇ MIDI'; } if (pdfUrl) html += '⬇ PDF'; html += '
'; return html; } // ───────────────────────────────────────────────────────────────────────────── function buildSvgPagesHtml(data) { var pages = data.page_count || 1; var baseId = data.id; // Use a random suffix so duplicate renders in chat history never share the same navId var navId = 'music-nav-' + Math.random().toString(36).slice(2, 10); var src1 = pages === 1 ? data.svg_url : '/api/music/file/' + baseId + '/svg/1'; var nav = ''; if (pages > 1) { nav = ''; } return nav + '악보'; } function musicPageNav(navId, delta) { var nav = document.getElementById(navId); if (!nav) return; var cur = parseInt(nav.dataset.page, 10); var total = parseInt(nav.dataset.total, 10); var next = cur + delta; if (next < 1 || next > total) return; nav.dataset.page = String(next); var baseId = nav.dataset.id; var img = document.getElementById(navId + '-img'); var lbl = document.getElementById(navId + '-label'); var prev = document.getElementById(navId + '-prev'); var nxt = document.getElementById(navId + '-next'); var url = '/api/music/file/' + baseId + '/svg/' + next; if (img) img.src = url; if (lbl) lbl.textContent = next + ' / ' + total; if (prev) prev.disabled = next <= 1; if (nxt) nxt.disabled = next >= total; } async function renderMusicBlock(widgetId, source) { var widget = document.getElementById(widgetId); if (!widget) return; var btn = widget.querySelector('.music-render-btn'); var output = widget.querySelector('.music-output'); if (btn) { btn.disabled = true; btn.textContent = '렌더링 중…'; } try { var data = await api('/api/music/render', { method: 'POST', body: JSON.stringify({ source: source }), }); if (data.error) throw new Error(data.error); var html = buildSvgPagesHtml(data); html += buildMusicActions(data, 'score.midi'); if (output) { output.innerHTML = html; output.style.display = 'block'; // Update chatHistory so the rendered output survives page reload persistActiveChat(); } if (btn) { btn.textContent = '다시 렌더링'; btn.disabled = false; } } catch (e) { if (output) { output.innerHTML = '렌더링 실패: ' + escHtml(e.message) + ''; output.style.display = 'block'; } if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; } } } // ─── MIDI 업로드 → 액션 피커 ────────────────────────────────────────────────── function buildMidiPickerWidget(data, filename) { var wid = 'midi-' + Math.random().toString(36).slice(2, 10); var midiUrl = data.midi_url; var rid = 'mr-' + Math.random().toString(36).slice(2, 10); var savedLine = data.saved_to ? '
💾 ' + escHtml(data.saved_to) + '
' : ''; // MIDI player panel (hidden by default) — includes MIDI download var playerPanel = ''; // Score area — always hidden by default; content pre-filled if already rendered var scoreInner = (data.has_svg && data.svg_url) ? buildSvgPagesHtml(data) + buildMusicActions(data, filename) : ''; var scoreArea = ''; var scoreBtnLabel = '🎼 악보'; var html = '
' + '
' + '🎵 ' + escHtml(filename) + '' + '
' + '' + '' + '
' + '
' + '
' + savedLine + '
' + '' + '' + '' + '
' + playerPanel + scoreArea + '
' + '
'; return { wid, html }; } async function midiPickerScore(wid, midiId, filename) { var scoreDiv = document.getElementById(wid + '-score'); var btn = document.getElementById(wid + '-score-btn'); if (!scoreDiv) return; // If score already rendered, toggle visibility if (scoreDiv.style.display !== 'none' && scoreDiv.innerHTML.trim()) { scoreDiv.style.display = 'none'; if (btn) btn.textContent = '📄 악보'; return; } if (scoreDiv.innerHTML.trim()) { scoreDiv.style.display = ''; if (btn) btn.textContent = '📄 닫기'; return; } // Show progress scoreDiv.style.display = ''; scoreDiv.innerHTML = '
📄 악보 변환 중...
'; if (btn) btn.disabled = true; try { var resp = await fetch('/api/music/midi-score', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ id: midiId }), }); if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status); var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = ''; while (true) { var chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); var parts = buf.split('\n\n'); buf = parts.pop() || ''; for (var i = 0; i < parts.length; i++) { if (!parts[i].startsWith('data: ')) continue; var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; } if (ev.type === 'progress') { var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message; } else if (ev.type === 'done') { scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename); if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; } } else if (ev.type === 'error') { scoreDiv.innerHTML = '악보 변환 실패: ' + escHtml(ev.message) + ''; if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; } } } } } catch (e) { scoreDiv.innerHTML = '오류: ' + escHtml(e.message) + ''; if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; } } } async function uploadMidiAndRender(file) { var msgId = 'midi-upload-' + Date.now(); appendChatBubble('system', '
🎵 ' + escHtml(file.name) + ' 업로드 중...
' ); try { var form = new FormData(); form.append('file', file); var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form }); var data = await resp.json(); if (!resp.ok) throw new Error(data.error || '서버 오류 ' + resp.status); var result = buildMidiPickerWidget(data, file.name); var placeholder = document.getElementById(msgId); if (placeholder) placeholder.outerHTML = result.html; chatHistory.push({ role: 'widget', content: result.html }); persistActiveChat(); } catch (e) { var pl = document.getElementById(msgId); if (pl) pl.outerHTML = '업로드 실패: ' + escHtml(e.message) + ''; } } function appendChatBubble(role, html) { var wrap = document.getElementById('chat-messages'); if (!wrap) return; var div = document.createElement('div'); div.className = 'msg msg-' + role; div.innerHTML = '
' + html + '
'; wrap.appendChild(div); wrap.scrollTop = wrap.scrollHeight; } function openImageZoom(src) { var modal = document.getElementById('image-zoom-modal'); var img = document.getElementById('image-zoom-img'); if (modal && img) { img.src = src; modal.style.display = 'flex'; } } function closeImageZoom() { var modal = document.getElementById('image-zoom-modal'); if (modal) modal.style.display = 'none'; } // Cache of checked /api/files/ URLs: url -> true (exists) | false (not found) var _dlLinkCache = {}; function markDownloadLinkError(btn) { var href = btn.getAttribute ? btn.getAttribute('href') : (btn.href || ''); var filename = decodeURIComponent((href || '').split('/').pop() || '파일'); _dlLinkCache[href] = false; var span = document.createElement('span'); span.className = 'download-btn-error'; span.title = '파일을 찾을 수 없음: ' + filename; span.textContent = '⚠ ' + filename + ' (파일 없음)'; if (btn.parentNode) btn.parentNode.replaceChild(span, btn); } function validateDownloadLinks(container) { var buttons = (container || document).querySelectorAll('a.download-btn[href]'); buttons.forEach(function(btn) { var url = btn.getAttribute('href'); if (!url || url.indexOf('/api/files/') === -1) return; if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; } if (_dlLinkCache[url] === true || _dlLinkCache[url] === 'pending') return; _dlLinkCache[url] = 'pending'; fetch(url, { method: 'HEAD', headers: authHeaders() }) .then(function(r) { _dlLinkCache[url] = r.ok; if (!r.ok) { var found = (container || document).querySelector('a.download-btn[href="' + url.replace(/"/g, '\\"') + '"]'); if (found) markDownloadLinkError(found); } }) .catch(function() { _dlLinkCache[url] = false; }); }); } function downloadFile(e, el) { e.preventDefault(); e.stopPropagation(); var url = el.getAttribute('href'); if (!url) return; // Ensure absolute URL (handle sandboxed environments where origin may be sandbox:/) if (!url.match(/^https?:\/\//)) { if (url.charAt(0) === '/') { // Already an absolute path — use relative to current host url = url; } else { url = new URL(url, window.location.origin).href; } } var filename = decodeURIComponent(url.split('/').pop() || 'download'); fetch(url, { headers: authHeaders() }) .then(function(r) { if (!r.ok) throw new Error(r.status); return r.blob(); }) .then(function(blob) { var blobUrl = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); }) .catch(function(err) { markDownloadLinkError(el); }); } function togglePptxPreview(fileUrl, containerId, btn) { var container = document.getElementById(containerId); if (!container) return; var isVisible = container.style.display !== 'none'; if (isVisible) { container.style.display = 'none'; btn.innerHTML = '◀ Preview'; return; } // Show loading state container.style.display = 'flex'; container.innerHTML = 'Generating preview...'; btn.innerHTML = '▲ Preview'; btn.classList.add('loading'); // Extract relative path from various URL formats: // /api/files/Folder/file.pptx → Folder/file.pptx // https://host/api/files/Folder/file.pptx → Folder/file.pptx // Folder/file.pptx → Folder/file.pptx var filePath = fileUrl; // Handle sandboxed environments where origin may be sandbox:/ if (fileUrl.charAt(0) === '/') { filePath = fileUrl; // Already an absolute path, use as-is } else { try { filePath = new URL(fileUrl, window.location.origin).pathname; } catch(e) {} } filePath = filePath.replace(/^\/api\/files\/?/, ''); // If the link itself is a preview URL, extract the path query parameter var previewMatch = filePath.match(/^\/api\/pptx\/preview\?path=(.+)$/); if (previewMatch) { filePath = previewMatch[1]; } // Safely decode - handle already-decoded or invalid encodings try { filePath = decodeURIComponent(filePath); } catch(e) {} fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), { headers: authHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { btn.classList.remove('loading'); if (!data.success || !data.images || !data.images.length) { container.innerHTML = 'Preview failed: ' + escHtml(data.error || 'No slides found') + ''; return; } // img 태그는 Bearer 헤더를 보낼 수 없어서 auth 없이 요청 → user context 미설정 → 파일 못 찾음. // fetch()로 auth 포함해 가져온 뒤 Blob URL로 교체. Promise.all(data.images.map(function(imgUrl) { return fetch(imgUrl, { headers: authHeaders() }) .then(function(r) { return r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status)); }) .then(function(blob) { return URL.createObjectURL(blob); }) .catch(function() { return imgUrl; }); })).then(function(srcs) { container.innerHTML = srcs.map(function(src, i) { return 'Slide ' + (i + 1) + ''; }).join(''); }); }) .catch(function(err) { btn.classList.remove('loading'); container.innerHTML = 'Preview error: ' + escHtml(err.message) + ''; }); } function handleImageUpload(input) { var file = input.files && input.files[0]; if (!file) return; input.value = ''; var formData = new FormData(); formData.append('image', file); fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: formData }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.path) { var chatInput = document.getElementById('chat-input'); var imgRef = '![image](/api/files/' + data.path + ')'; chatInput.value = chatInput.value ? chatInput.value + '\n' + imgRef : imgRef; chatInput.focus(); } else { alert(data.error || 'Upload failed'); } }) .catch(function(e) { alert('Upload error: ' + e.message); }); } // ── 파일 첨부 (이미지·PDF·Excel·txt·csv 등) ───────────────────────────────── function handleFileUpload(input) { var file = input.files && input.files[0]; if (!file) return; input.value = ''; var name = file.name.toLowerCase(); // MIDI → 악보 변환 if (/\.(mid|midi)$/.test(name) || file.type === 'audio/midi' || file.type === 'audio/x-midi') { uploadMidiAndRender(file); return; } // 이미지 if (file.type.startsWith('image/')) { var imgForm = new FormData(); imgForm.append('image', file); fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: imgForm }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.path) { insertIntoChatInput('![' + file.name + '](/api/files/' + data.path + ')'); } else { alert(data.error || '이미지 업로드 실패'); } }) .catch(function(e) { alert('업로드 오류: ' + e.message); }); // txt / md } else if (/\.(txt|md)$/.test(name)) { var r1 = new FileReader(); r1.onload = function(e) { var ext = name.split('.').pop(); stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```' + ext + '\n' + e.target.result + '\n```', 'text'); }; r1.readAsText(file, 'UTF-8'); // CSV } else if (/\.csv$/.test(name)) { var r2 = new FileReader(); r2.onload = function(e) { stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```csv\n' + e.target.result + '\n```', 'csv'); }; r2.readAsText(file, 'UTF-8'); // Excel } else if (/\.(xlsx|xls)$/.test(name)) { var xlsxFile = file; var xlsxForm = new FormData(); xlsxForm.append('file', xlsxFile); var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: xlsxForm }) .then(function(r) { return r.json(); }).catch(function() { return {}; }); loadSheetJS(function() { var r3 = new FileReader(); r3.onload = function(e) { try { var wb = XLSX.read(e.target.result, { type: 'array' }); var markdownContent = '**[파일: ' + xlsxFile.name + ']**\n\n'; var previewHtml = ''; wb.SheetNames.forEach(function(sn) { var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 }); markdownContent += '### ' + sn + '\n'; rows.forEach(function(row) { markdownContent += '| ' + row.map(function(c) { return String(c ?? ''); }).join(' | ') + ' |\n'; }); markdownContent += '\n'; previewHtml += '
' + escHtml(sn) + '
'; previewHtml += '
'; rows.slice(0, 30).forEach(function(row, ri) { previewHtml += ''; row.forEach(function(cell) { var tag = ri === 0 ? 'th' : 'td'; previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + ''; }); previewHtml += ''; }); if (rows.length > 30) previewHtml += ''; previewHtml += '
… ' + (rows.length - 30) + '행 더
'; }); xlsxUploadPromise.then(function(d) { var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null; stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml }); }); } catch(err) { alert('Excel 파싱 오류: ' + err.message); } }; r3.readAsArrayBuffer(xlsxFile); }); // PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장 } else if (/\.pdf$/.test(name)) { var pdfFile = file; var pdfForm = new FormData(); pdfForm.append('file', pdfFile); var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm }) .then(function(r) { return r.json(); }); var extractPromise = new Promise(function(resolve) { loadPdfJs(function() { var fr = new FileReader(); fr.onload = function(e) { pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise .then(function(pdf) { var pages = []; for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i)); Promise.all(pages).then(function(ps) { return Promise.all(ps.map(function(p, pi) { return p.getTextContent().then(function(tc) { return pdfPageToText(tc.items, pi + 1, pdf.numPages); }); })); }).then(function(texts) { resolve({ text: texts.join('\n\n'), pages: pdf.numPages }); }).catch(function() { resolve({ text: '', pages: 0 }); }); }).catch(function() { resolve({ text: '', pages: 0 }); }); }; fr.readAsArrayBuffer(pdfFile); }); }); Promise.all([uploadPromise, extractPromise]).then(function(res) { var upData = res[0], extracted = res[1]; var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null; var fullText = (extracted.text || '').trim(); if (fullText) { var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt'); var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' }); var txtForm = new FormData(); txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' })); fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm }) .then(function(r) { return r.json(); }) .then(function(d) { var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null; if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName); var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')'; if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')'; content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.'; stageAttachment(pdfFile.name, fileUrl, content, 'pdf', { txtPreview: fullText.slice(0, 500), txtUrl: txtUrl, txtName: txtName, pages: extracted.pages }); }).catch(function() { var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n이 파일로 무엇을 도와드릴까요?'; stageAttachment(pdfFile.name, fileUrl, content, 'pdf', { txtPreview: fullText.slice(0, 500), pages: extracted.pages }); }); } else { var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*'; stageAttachment(pdfFile.name, fileUrl, content, 'pdf'); } }).catch(function(e) { alert('PDF 처리 오류: ' + e.message); }); // 기타 (docx/pptx 등) } else { var otherForm = new FormData(); otherForm.append('file', file); fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.path) { insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')'); } else { alert(data.error || '파일 업로드 실패'); } }) .catch(function(e) { alert('업로드 오류: ' + e.message); }); } } function insertIntoChatInput(text) { var el = document.getElementById('chat-input'); el.value = el.value ? el.value + '\n' + text : text; el.focus(); el.dispatchEvent(new Event('input')); } // ── 첨부 파일 스테이징 ────────────────────────────────────────────────────── var stagedAttachments = []; function stageAttachment(name, url, content, type, extra) { var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); var att = { id: id, name: name, url: url, content: content, type: type }; if (extra) Object.assign(att, extra); stagedAttachments.push(att); renderStagedAttachments(); } function removeStagedAttachment(id) { stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; }); renderStagedAttachments(); } function clearStagedAttachments() { stagedAttachments = []; renderStagedAttachments(); } function renderStagedAttachments() { var container = document.getElementById('staged-attachments'); if (!container) return; if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; } container.style.display = 'flex'; container.innerHTML = stagedAttachments.map(function(a) { var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝'; var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px'; // PDF 카드 if (a.type === 'pdf') { var txtPreview = a.txtPreview ? escHtml(a.txtPreview) + (a.txtPreview.length >= 500 ? '...' : '') : ''; var pagesInfo = a.pages ? ' (' + a.pages + '페이지)' : ''; return '
' + '
' + '📄' + '' + escHtml(a.name) + '' + (txtPreview ? '' : '') + (a.url ? '' : '') + (a.txtUrl ? '📝⬇' : '') + '' + '
' + (pagesInfo ? '
텍스트 추출 완료' + pagesInfo + '
' : '') + (txtPreview ? '' : '') + '' + '
'; } // TXT 자동저장 카드 (미리보기 + 다운로드) if (a.type === 'txt-auto') { var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : ''); return '
' + '
' + '📝' + '' + escHtml(a.name) + '' + '' + (a.url ? '⬇ 저장' : '') + '' + '
' + '' + '
'; } // excel 카드 if (a.type === 'excel') { return '
' + '
' + '📊' + '' + escHtml(a.name) + '' + (a.excelPreviewHtml ? '' : '') + (a.url ? '⬇ 저장' : '') + '' + '
' + (a.excelPreviewHtml ? '' : '') + '
'; } // 기타 카드 (text, csv) return '
' + '
' + '' + icon + '' + '' + escHtml(a.name) + '' + '' + '
' + '
'; }).join(''); } function toggleStagedPdfPreview(id) { var el = document.getElementById('pdf-preview-' + id); if (!el) return; var att = stagedAttachments.find(function(a) { return a.id === id; }); if (el.style.display === 'none') { el.style.display = 'block'; var iframe = el.querySelector('iframe'); if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정 } else { el.style.display = 'none'; } } function toggleStagedTxtPreview(id) { var el = document.getElementById('txt-preview-' + id); if (!el) return; el.style.display = el.style.display === 'none' ? 'block' : 'none'; } function toggleStagedTxtPdfPreview(id) { var el = document.getElementById('txt-pdf-preview-' + id); if (!el) return; el.style.display = el.style.display === 'none' ? 'block' : 'none'; } // PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출) function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) { // stagedAttachments에서 해당 PDF 찾기 var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; }); if (!att) return; // txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용) att.txtName = txtName; att.txtUrl = txtUrl; // 카드 DOM에 txt 버튼 삽입 var card = document.getElementById('card-' + att.id); if (!card) return; var btnRow = card.querySelector('div'); if (!btnRow) return; var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block'; var txtBtn = document.createElement('a'); txtBtn.href = txtUrl; txtBtn.download = txtName; txtBtn.title = txtName + ' 다운로드'; txtBtn.style.cssText = btnStyle; txtBtn.innerHTML = '📝 txt ⬇'; // ✕ 버튼 바로 앞에 삽입 var closeBtn = btnRow.lastElementChild; btnRow.insertBefore(txtBtn, closeBtn); } // ── PDF 페이지 텍스트 구조화 ──────────────────────────────────────────────── function pdfPageToText(items, pageNum, totalPages) { // filter out empty items var valid = items.filter(function(it) { return it.str && it.str.trim() !== ''; }); if (!valid.length) return ''; // sort top→bottom (y desc), left→right (x asc) valid.sort(function(a, b) { var ay = a.transform[5], by = b.transform[5]; if (Math.abs(ay - by) > 2) return by - ay; return a.transform[4] - b.transform[4]; }); // group items into lines by y-coordinate proximity var LINE_Y_THRESH = 3; var lines = []; var cur = null; valid.forEach(function(item) { var y = item.transform[5]; var h = item.height || 10; if (!cur || Math.abs(y - cur.y) > LINE_Y_THRESH) { cur = { y: y, h: h, items: [item] }; lines.push(cur); } else { cur.items.push(item); if (h > cur.h) cur.h = h; } }); // build output: join items per line; insert blank line on large y-gaps (paragraph break) var out = []; if (totalPages > 1) out.push('── ' + pageNum + '페이지 ──'); for (var i = 0; i < lines.length; i++) { var lineText = lines[i].items.map(function(it) { return it.str; }).join('').replace(/\s+/g, ' ').trim(); if (!lineText) continue; // paragraph break: gap > 1.4× current line height if (i > 0) { var gap = lines[i - 1].y - lines[i].y; if (gap > lines[i - 1].h * 1.4) out.push(''); } // fix hyphenation: merge "단어-\n다음줄" → "단어다음줄" if (out.length && out[out.length - 1].endsWith('-')) { out[out.length - 1] = out[out.length - 1].slice(0, -1) + lineText; } else { out.push(lineText); } } return out.join('\n'); } // ── SheetJS / PDF.js / x-spreadsheet 동적 로드 ────────────────────────────────────────────── var _sheetJsLoaded = false; function loadSheetJS(cb) { if (_sheetJsLoaded) return cb(); var s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'; s.onload = function() { _sheetJsLoaded = true; cb(); }; document.head.appendChild(s); } var _luckyLoaded = false; function loadLuckysheet(cb) { if (_luckyLoaded) return cb(); var cssList = [ 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css', ]; cssList.forEach(function(href) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l); }); var scripts = [ 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js', 'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js', ]; function next(i) { if (i >= scripts.length) { _luckyLoaded = true; cb(); return; } var s = document.createElement('script'); s.src = scripts[i]; s.onload = function() { next(i + 1); }; s.onerror = function() { next(i + 1); }; // 실패해도 진행 document.head.appendChild(s); } next(0); } var _pdfJsLoaded = false; function loadPdfJs(cb) { if (_pdfJsLoaded) return cb(); var s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; s.onload = function() { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; _pdfJsLoaded = true; cb(); }; document.head.appendChild(s); } function timeAgo(ts) { const diff = Date.now() - ts; if (diff < 60000) return 'just now'; if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`; return `${Math.floor(diff/86400000)}d ago`; } function openOllamaInfo() { window.open('http://localhost:11434','_blank'); } // ---- Skills management (v2 - local skills system) ---- let skillsCache = []; let editingSkillId = null; let skillsTargetUser = ''; // '' = global (admin) or own username (regular user) async function initSkillsUserSelector() { const selector = document.getElementById('skills-user-selector'); if (!selector) return; if (!currentUser) { selector.style.display = 'none'; return; } if (currentUser.role !== 'admin') { selector.style.display = 'none'; skillsTargetUser = currentUser.username || ''; return; } selector.style.display = 'block'; try { const data = await api('/api/users'); const sel = document.getElementById('skills-target-user'); if (!sel || !data.users) return; const opts = ['']; for (const u of data.users) { const selected = u.username === skillsTargetUser ? ' selected' : ''; opts.push(``); } sel.innerHTML = opts.join(''); } catch {} } function onSkillsUserChange() { const sel = document.getElementById('skills-target-user'); skillsTargetUser = sel ? sel.value : ''; loadInstalledSkills(); } async function loadInstalledSkills() { const el = document.getElementById('skills-list'); el.innerHTML = '
Loading...
'; try { const effectiveUser = skillsTargetUser || currentUser?.username || null; const url = effectiveUser ? `/api/skills?username=${encodeURIComponent(effectiveUser)}` : '/api/skills'; const data = await api(url); const skills = data.skills || []; skillsCache = skills; if (skills.length === 0) { el.innerHTML = '
No skills yet.
Click "+ Create" to make one!
'; } else { el.innerHTML = skills.map(s => { const skillEmoji = (s.emoji && s.emoji !== '🦞' && s.emoji !== '⚠️') ? s.emoji : '🧩'; const globalBadge = s.globalOnly ? '[전체]' : ''; return `
${escHtml(skillEmoji)}
${escHtml(s.name)}${globalBadge}
${escHtml(s.description || 'No description')}
`; }).join(''); renderSkillEligibility(skills); } } catch (err) { el.innerHTML = `
Error: ${err.message}
`; } } async function toggleSkill(id) { const skillMeta = (skillsCache || []).find(s => s.id === id); if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) { addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.'); return; } // globalOnly 스킬은 per-user 모드에서 토글 불가 (서버가 전역으로 처리) if (skillMeta?.globalOnly && skillsTargetUser) { addProcessEntry('error', `"${skillMeta.name}" 스킬은 글로벌 전용입니다.`); return; } try { const effectiveUser = skillsTargetUser || currentUser?.username || null; const body = effectiveUser ? JSON.stringify({ username: effectiveUser }) : undefined; const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST', body }); if (!res.success) { addProcessEntry('error', res.error || 'Failed to toggle skill.'); return; } const label = res.targetUser ? ` (${res.targetUser})` : ''; addProcessEntry('info', `Skill "${res.skill.name}"${label}: ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`); loadInstalledSkills(); } catch (err) { addProcessEntry('error', `Failed to toggle skill: ${err.message}`); } } async function deleteSkill(id) { if (!confirm(`Delete skill "${id}"?`)) return; try { const res = await api(`/api/skills/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.success) { addProcessEntry('info', `Skill "${id}" deleted.`); loadInstalledSkills(); } else { alert(`Delete failed: ${res.error}`); } } catch (err) { alert(`Error: ${err.message}`); } } function openCreateSkillModal() { editingSkillId = null; document.getElementById('skill-modal-title').textContent = '스킬 생성'; document.getElementById('skill-modal-save-btn').textContent = '스킬 생성'; document.getElementById('skill-modal-name').value = ''; document.getElementById('skill-modal-emoji').value = ''; document.getElementById('skill-modal-desc').value = ''; document.getElementById('skill-modal-instructions').value = ''; document.getElementById('skill-modal').style.display = 'flex'; document.getElementById('skill-modal-name').focus(); } async function editSkill(id) { try { const res = await api(`/api/skills/${encodeURIComponent(id)}`); if (!res.success) { alert('Skill not found'); return; } const s = res.skill; editingSkillId = id; document.getElementById('skill-modal-title').textContent = '스킬 편집'; document.getElementById('skill-modal-save-btn').textContent = '변경 저장'; document.getElementById('skill-modal-name').value = s.name || ''; document.getElementById('skill-modal-emoji').value = s.emoji || ''; document.getElementById('skill-modal-desc').value = s.description || ''; document.getElementById('skill-modal-instructions').value = s.instructions || ''; document.getElementById('skill-modal').style.display = 'flex'; document.getElementById('skill-modal-name').focus(); } catch (err) { alert(`Error loading skill: ${err.message}`); } } function closeSkillModal() { document.getElementById('skill-modal').style.display = 'none'; editingSkillId = null; } async function saveSkillFromModal() { const name = document.getElementById('skill-modal-name').value.trim(); const rawEmoji = document.getElementById('skill-modal-emoji').value.trim(); const emoji = (rawEmoji === '🧩' || rawEmoji === '🦞' || rawEmoji === '⚠️') ? rawEmoji : '🧩'; const description = document.getElementById('skill-modal-desc').value.trim(); const instructions = document.getElementById('skill-modal-instructions').value.trim(); if (!name) { alert('Name is required'); return; } if (!instructions) { alert('Instructions are required'); return; } const btn = document.getElementById('skill-modal-save-btn'); btn.disabled = true; btn.textContent = '저장 중...'; try { let res; if (editingSkillId) { // Update existing res = await api(`/api/skills/${encodeURIComponent(editingSkillId)}`, { method: 'PUT', body: JSON.stringify({ name, emoji, description, instructions }), }); } else { // Create new res = await api('/api/skills', { method: 'POST', body: JSON.stringify({ name, emoji, description, instructions }), }); } if (res.success) { addProcessEntry('info', `Skill "${name}" ${editingSkillId ? 'updated' : 'created'}!`); closeSkillModal(); loadInstalledSkills(); } else { alert(`Failed: ${res.error}`); } } catch (err) { alert(`Error: ${err.message}`); } finally { btn.disabled = false; btn.textContent = editingSkillId ? '변경 저장' : '스킬 생성'; } } // ---- Stop Button ---- let currentAbortController = null; function handleSendStop() { if (isThinking) stopGeneration(); else sendChat(); } function stopGeneration() { if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } } function setButtonState(thinking) { const sendIcon = document.getElementById('send-icon'); const stopIcon = document.getElementById('stop-icon'); const btn = document.getElementById('send-btn'); if (!btn) return; if (thinking) { if (sendIcon) sendIcon.style.display = 'none'; if (stopIcon) stopIcon.style.display = ''; btn.style.background = 'var(--err)'; btn.title = 'Stop generation'; } else { if (sendIcon) sendIcon.style.display = ''; if (stopIcon) stopIcon.style.display = 'none'; btn.style.background = ''; btn.title = 'Send'; } } // ---- Context Pinning ---- const MAX_PINS = 3; let contextPinMode = false; let pinnedMessageIndices = new Set(); let confirmedPins = []; // Array of { role, content } sent with each request function toggleContextPinMode() { const pop = document.getElementById('context-pin-popover'); if (!pop) return; const isOpen = pop.classList.contains('open'); // Close other popovers document.getElementById('quick-mode-popover')?.classList.remove('open'); if (isOpen) { cancelContextPin(); } else { contextPinMode = true; pop.classList.add('open'); document.body.classList.add('context-pin-active'); // Reset selection to currently confirmed pins pinnedMessageIndices = new Set(); for (const pin of confirmedPins) { const idx = chatHistory.findIndex(m => m.content === pin.content && m.role === pin.role); if (idx >= 0) pinnedMessageIndices.add(idx); } updatePinUI(); renderChatMessages(); } } function cancelContextPin() { contextPinMode = false; pinnedMessageIndices = new Set(); document.getElementById('context-pin-popover')?.classList.remove('open'); document.body.classList.remove('context-pin-active'); renderChatMessages(); } function confirmContextPin() { confirmedPins = []; for (const idx of pinnedMessageIndices) { const msg = chatHistory[idx]; if (msg) confirmedPins.push({ role: msg.role === 'user' ? 'user' : 'assistant', content: msg.content }); } contextPinMode = false; document.getElementById('context-pin-popover')?.classList.remove('open'); document.body.classList.remove('context-pin-active'); updatePinBadge(); renderChatMessages(); if (confirmedPins.length > 0) { addProcessEntry('info', `${confirmedPins.length} message(s) pinned to context.`); } else { addProcessEntry('info', 'Context pins cleared.'); } } function togglePinMessage(index) { if (!contextPinMode) return; if (pinnedMessageIndices.has(index)) { pinnedMessageIndices.delete(index); } else if (pinnedMessageIndices.size < MAX_PINS) { pinnedMessageIndices.add(index); } updatePinUI(); renderChatMessages(); } function removePinFromList(index) { pinnedMessageIndices.delete(index); updatePinUI(); renderChatMessages(); } function updatePinUI() { const countEl = document.getElementById('context-pin-count'); const listEl = document.getElementById('context-pin-list'); if (countEl) countEl.textContent = `${pinnedMessageIndices.size}/${MAX_PINS} selected`; if (listEl) { if (pinnedMessageIndices.size === 0) { listEl.innerHTML = '
Click messages in chat to select them
'; } else { listEl.innerHTML = Array.from(pinnedMessageIndices).map(idx => { const msg = chatHistory[idx]; if (!msg) return ''; const icon = msg.role === 'user' ? '??' : '??'; const text = msg.content.slice(0, 60) + (msg.content.length > 60 ? '...' : ''); return `
${icon}
${escHtml(text)}
`; }).join(''); } } } function updatePinBadge() { const label = document.getElementById('context-pin-label'); if (label) { label.textContent = confirmedPins.length > 0 ? `컨텍스트 (${confirmedPins.length})` : '컨텍스트'; } const btn = document.getElementById('context-pin-btn'); if (btn) { btn.style.borderColor = confirmedPins.length > 0 ? '#86d4a8' : ''; btn.style.background = confirmedPins.length > 0 ? '#f0faf4' : ''; btn.style.color = confirmedPins.length > 0 ? '#0a6b3d' : ''; } } function getInitialTheme() { try { const saved = localStorage.getItem(THEME_KEY); if (saved === 'light' || saved === 'dark') return saved; } catch {} return 'light'; } function applyTheme(theme) { const resolved = theme === 'dark' ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', resolved); const toggle = document.getElementById('theme-toggle'); if (toggle) { toggle.setAttribute('data-theme-state', resolved); const title = resolved === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; toggle.title = title; toggle.setAttribute('aria-label', title); } try { localStorage.setItem(THEME_KEY, resolved); } catch {} } function toggleTheme() { const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; applyTheme(current === 'dark' ? 'light' : 'dark'); } // ---- Boot ---- document.addEventListener('click', (e) => { const pop = document.getElementById('quick-mode-popover'); const btn = document.getElementById('quick-mode-btn'); if (!pop || !btn) return; if (!pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('open'); // Also close context pin popover const pinPop = document.getElementById('context-pin-popover'); const pinBtn = document.getElementById('context-pin-btn'); if (pinPop && pinBtn && !pinPop.contains(e.target) && !pinBtn.contains(e.target)) { // Don't close if in pin mode and clicking on messages if (contextPinMode && e.target.closest && e.target.closest('.msg')) return; } }); // --- SCHEDULER MANAGEMENT --------------------------------------------------- let schedules = []; let editingScheduleId = null; async function refreshSchedules() { try { const result = await api('/api/schedules'); if (result.success && Array.isArray(result.schedules)) { schedules = result.schedules; renderScheduleList(); } } catch (err) { console.error('Failed to load schedules:', err); } } function renderScheduleList() { const list = document.getElementById('schedule-list'); const count = document.getElementById('schedule-count'); if (!list) return; if (schedules.length === 0) { list.innerHTML = '
No schedules yet. + 새 일정 to get started.
'; if (count) count.textContent = '0 schedules'; return; } if (count) count.textContent = `${schedules.length} schedule${schedules.length !== 1 ? 's' : ''}`; list.innerHTML = schedules.map(job => { const nextRun = job.next_run ? new Date(job.next_run).toLocaleString() : 'Never'; const lastRun = job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'; const statusClass = job.status === 'active' ? 'status-active' : job.status === 'paused' ? 'status-paused' : 'status-failed'; return `
${job.name || 'Untitled'}
${job.status || 'unknown'}
${job.prompt ? job.prompt.slice(0, 60) + (job.prompt.length > 60 ? '...' : '') : 'No prompt'}
Next: ${nextRun}
Last: ${lastRun}
`; }).join(''); } function openScheduleCreateModal() { editingScheduleId = null; document.getElementById('schedule-modal-title').textContent = '일정 생성'; document.getElementById('schedule-save-btn').textContent = '일정 생성'; document.getElementById('schedule-name').value = ''; document.getElementById('schedule-pattern').value = ''; document.getElementById('schedule-prompt').value = ''; document.getElementById('schedule-timezone').value = 'UTC'; document.getElementById('schedule-channel').value = 'web'; document.getElementById('schedule-pattern-preview').style.display = 'none'; document.getElementById('schedule-modal').style.display = 'flex'; } function editSchedule(jobId) { const job = schedules.find(j => j.id === jobId); if (!job) return; editingScheduleId = jobId; document.getElementById('schedule-modal-title').textContent = '일정 편집'; document.getElementById('schedule-save-btn').textContent = '변경 저장'; document.getElementById('schedule-name').value = job.name || ''; document.getElementById('schedule-pattern').value = job.cron || job.run_at || ''; document.getElementById('schedule-prompt').value = job.prompt || ''; document.getElementById('schedule-timezone').value = job.timezone || 'UTC'; document.getElementById('schedule-channel').value = job.delivery_channel || 'web'; document.getElementById('schedule-pattern-preview').style.display = 'none'; document.getElementById('schedule-modal').style.display = 'flex'; } function closeScheduleModal() { document.getElementById('schedule-modal').style.display = 'none'; editingScheduleId = null; } async function parseSchedulePattern() { const pattern = document.getElementById('schedule-pattern').value.trim(); const timezone = document.getElementById('schedule-timezone').value; if (!pattern) { alert('Enter a schedule pattern (e.g., "daily at 09:00" or "0 9 * * *")'); return; } try { const result = await api('/api/schedules/parse', { method: 'POST', body: JSON.stringify({ text: pattern, timezone }) }); const preview = document.getElementById('schedule-pattern-preview'); if (result.success) { preview.textContent = `✓ ${result.preview || result.human_text || 'Valid pattern'}`; preview.style.display = 'block'; preview.style.color = 'var(--muted)'; } else { preview.textContent = `⚠ ${result.error || 'Invalid pattern'}`; preview.style.display = 'block'; preview.style.color = '#ff6b6b'; } } catch (err) { document.getElementById('schedule-pattern-preview').textContent = '⚠ Parse failed'; document.getElementById('schedule-pattern-preview').style.display = 'block'; document.getElementById('schedule-pattern-preview').style.color = '#ff6b6b'; } } async function saveSchedule() { const name = document.getElementById('schedule-name').value.trim(); const pattern = document.getElementById('schedule-pattern').value.trim(); const prompt = document.getElementById('schedule-prompt').value.trim(); const timezone = document.getElementById('schedule-timezone').value; const channel = document.getElementById('schedule-channel').value; if (!name) return alert('Schedule name is required'); if (!pattern) return alert('Schedule pattern is required'); if (!prompt) return alert('Prompt/action is required'); const method = editingScheduleId ? 'PUT' : 'POST'; const path = editingScheduleId ? `/api/schedules/${editingScheduleId}` : '/api/schedules'; try { const result = await api(path, { method, body: JSON.stringify({ name, pattern, prompt, timezone, delivery_channel: channel, confirm: true // Confirmation (as per scheduler rules) }) }); if (result.success) { closeScheduleModal(); await refreshSchedules(); } else { alert('Error: ' + (result.error || 'Unknown error')); } } catch (err) { alert('Failed to save schedule: ' + err.message); } } async function deleteSchedule(jobId) { if (!confirm('Delete this schedule? This action cannot be undone.')) return; try { const result = await api(`/api/schedules/${jobId}`, { method: 'DELETE', body: JSON.stringify({ confirm: true }) }); if (result.success) { await refreshSchedules(); } else { alert('Error: ' + (result.error || 'Failed to delete')); } } catch (err) { alert('Failed to delete schedule: ' + err.message); } } async function toggleSchedulePause(jobId) { const job = schedules.find(j => j.id === jobId); if (!job) return; const newStatus = job.status === 'active' ? 'paused' : 'active'; try { const result = await api(`/api/schedules/${jobId}`, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) }); if (result.success) { await refreshSchedules(); } else { alert('Error: ' + (result.error || 'Failed to update')); } } catch (err) { alert('Failed to toggle pause: ' + err.message); } } async function runScheduleNow(jobId) { try { const result = await api(`/api/schedules/${jobId}/run`, { method: 'POST', body: JSON.stringify({}) }); if (result.success) { await refreshSchedules(); alert('Schedule running now...'); } else { alert('Error: ' + (result.error || 'Failed to run')); } } catch (err) { alert('Failed to run schedule: ' + err.message); } } loadChatSessions(); applyTheme(getInitialTheme()); setupProcessAndRightScrollTracking(); updateHeartbeatUI(); connectWS(); refreshAll(); updateQuickModeUI(); updateQueuedPromptUI(); setInterval(checkStatus, 10000); setInterval(loadSystemStats, 3000); setInterval(loadApprovals, 5000); setMode('chat'); // Start in chat mode // --------------------------------------------------------------------------- // BACKGROUND TASKS KANBAN SYSTEM // --------------------------------------------------------------------------- let bgtTasks = []; // all task records from server let bgtOpenTaskId = null; // currently open panel task id let bgtHeartbeatConfig = { enabled: true, interval_minutes: 10 }; function openBgtHeartbeatModal() { const modal = document.getElementById('bgt-heartbeat-modal'); const enabledEl = document.getElementById('bgt-hb-enabled'); const intervalEl = document.getElementById('bgt-hb-interval'); if (!modal || !enabledEl || !intervalEl) return; enabledEl.checked = !!bgtHeartbeatConfig.enabled; intervalEl.value = String(Math.max(1, Number(bgtHeartbeatConfig.interval_minutes) || 10)); modal.style.display = 'flex'; } function closeBgtHeartbeatModal() { const modal = document.getElementById('bgt-heartbeat-modal'); if (modal) modal.style.display = 'none'; } async function saveBgtHeartbeatConfig() { const enabledEl = document.getElementById('bgt-hb-enabled'); const intervalEl = document.getElementById('bgt-hb-interval'); if (!enabledEl || !intervalEl) return; const enabled = !!enabledEl.checked; const interval = Math.max(1, Math.min(1440, Number(intervalEl.value) || 10)); try { const data = await api('/api/bg-tasks/heartbeat/config', { method: 'PUT', body: JSON.stringify({ enabled, interval_minutes: interval }), }); if (data?.success && data.config) { bgtHeartbeatConfig = data.config; renderBgTasks(); closeBgtHeartbeatModal(); bgtToast('Heartbeat updated', enabled ? `Every ${interval} minute(s)` : 'Task heartbeat disabled'); return; } bgtToast('Heartbeat update failed', String(data?.error || 'Unknown error')); } catch (err) { bgtToast('Heartbeat update failed', String(err?.message || err)); } } document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const modal = document.getElementById('bgt-heartbeat-modal'); if (modal && modal.style.display === 'flex') closeBgtHeartbeatModal(); }); document.addEventListener('click', (e) => { const modal = document.getElementById('bgt-heartbeat-modal'); if (!modal || modal.style.display !== 'flex') return; if (e.target === modal) closeBgtHeartbeatModal(); }); // --- Columns config --------------------------------------------------------- const BGT_COLUMNS = [ { key: 'running', label: '● In Progress', color: '#0d4faf', bg: '#eaf2ff' }, { key: 'queued', label: '◷ Queued', color: '#7c4d00', bg: '#fff8e1' }, { key: 'paused', label: '‖ Paused', color: '#555', bg: '#f5f5f5' }, { key: 'stalled', label: '▲ Stalled', color: '#9c1a1a', bg: '#fff0f0' }, { key: 'needs_assistance', label: '⚠️ Needs You', color: '#6d2d9e', bg: '#f5eeff' }, { key: 'complete', label: '✓ Complete', color: '#1a6e35', bg: '#efffea' }, { key: 'failed', label: '✕ Failed', color: '#9c1a1a', bg: '#fff0f0' }, ]; const STATUS_ICON = { running: '●', queued: '◷', paused: '‖', stalled: '▲', needs_assistance: '⚠️', complete: '✓', failed: '✕', }; // --- Fetch & Render ---------------------------------------------------------- async function refreshBgTasks() { try { const data = await api('/api/bg-tasks'); if (data.success) { bgtTasks = data.tasks || []; bgtHeartbeatConfig = data.heartbeatConfig || bgtHeartbeatConfig; } } catch (err) { console.error('[BGT] refreshBgTasks error:', err); } renderBgTasks(); } function renderBgTasks() { const board = document.getElementById('bgt-board'); if (!board) return; // Update count badge const countEl = document.getElementById('bgt-count'); if (countEl) { const active = bgtTasks.filter(t => t.status !== 'complete' && t.status !== 'failed').length; countEl.textContent = `${active} active`; } // Update heartbeat label const hbLabel = document.getElementById('bgt-heartbeat-label'); if (hbLabel) { const mins = bgtHeartbeatConfig.interval_minutes || 10; const on = bgtHeartbeatConfig.enabled; hbLabel.textContent = on ? `• heartbeat: ${mins}min` : '• heartbeat: off'; hbLabel.style.color = on ? 'var(--brand)' : 'var(--muted)'; } // Only render non-empty columns const byStatus = {}; for (const col of BGT_COLUMNS) byStatus[col.key] = []; for (const t of bgtTasks) { if (byStatus[t.status]) byStatus[t.status].push(t); else if (byStatus['stalled']) byStatus['stalled'].push(t); } board.innerHTML = ''; for (const col of BGT_COLUMNS) { const tasks = byStatus[col.key]; if (tasks.length === 0 && (col.key === 'complete' || col.key === 'failed' || col.key === 'stalled' || col.key === 'needs_assistance')) continue; const colEl = document.createElement('div'); colEl.style.cssText = 'min-width:240px;max-width:280px;flex:0 0 auto;display:flex;flex-direction:column;gap:0;'; colEl.innerHTML = `
${col.label} ${tasks.length}
${tasks.map(t => bgtCardHTML(t, col)).join('')}
`; board.appendChild(colEl); if (tasks.length === 0) { // Empty placeholder colEl.querySelector('.bgt-col-cards').innerHTML = `
No ${col.label.split(' ').slice(1).join(' ').toLowerCase()} tasks
`; } } // If no tasks at all if (bgtTasks.length === 0) { board.innerHTML = `
작업
No background tasks yet
Ask CherryClaw to do something in the background and it'll appear here.
`; } } function bgtCardHTML(t, col) { const mins = Math.round((Date.now() - (t.lastProgressAt || t.startedAt)) / 60000); const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.round(mins/60)}h ago`; const stepsDone = (t.plan || []).filter(s => s.status === 'done' || s.status === 'skipped').length; const totalSteps = (t.plan || []).length; const pct = totalSteps > 0 ? Math.round(stepsDone / totalSteps * 100) : 0; const currentStep = (t.plan || [])[t.currentStepIndex || 0]; const channel = t.channel === 'telegram' ? 'Telegram' : 'Web UI'; return `
${escHtml(t.title || 'Untitled Task')}
${STATUS_ICON[t.status] || '?'}
${currentStep ? `
Step ${(t.currentStepIndex||0)+1}/${totalSteps}: ${escHtml(currentStep.description.slice(0,60))}${currentStep.description.length>60?'…':''}
` : ''} ${totalSteps > 0 ? `
` : ''}
${channel} ${timeAgo} ${t.status === 'needs_assistance' ? `Needs you` : ''} ${t.status === 'paused' ? `${t.pauseReason || 'paused'}` : ''}
`; } // --- Side Panel -------------------------------------------------------------- async function openBgtPanel(taskId) { bgtOpenTaskId = taskId; const panel = document.getElementById('bgt-panel'); if (panel) panel.style.display = 'flex'; // Clear chat input when switching tasks const chatInput = document.getElementById('bgt-chat-input'); if (chatInput) { chatInput.value = ''; chatInput.disabled = false; chatInput.style.opacity = ''; } await bgtRefreshOpenPanel(); // Highlight the card renderBgTasks(); } function closeBgtPanel() { bgtOpenTaskId = null; const panel = document.getElementById('bgt-panel'); if (panel) panel.style.display = 'none'; renderBgTasks(); } async function bgtRefreshOpenPanel() { if (!bgtOpenTaskId) return; let task; try { const data = await api(`/api/bg-tasks/${bgtOpenTaskId}`); if (data.success) task = data.task; } catch {} if (!task) { task = bgtTasks.find(t => t.id === bgtOpenTaskId); } if (!task) return; const titleEl = document.getElementById('bgt-panel-title'); if (titleEl) titleEl.textContent = task.title || '작업 상세'; const pauseBtn = document.getElementById('bgt-panel-pause'); if (pauseBtn) { if (task.status === 'running') { pauseBtn.textContent = '일시정지'; pauseBtn.style.display = ''; } else if (task.status === 'paused' || task.status === 'queued') { pauseBtn.textContent = '재개'; pauseBtn.style.display = ''; } else { pauseBtn.style.display = 'none'; } } const body = document.getElementById('bgt-panel-body'); if (!body) return; const plan = task.plan || []; const journal = task.journal || []; const stepsDone = plan.filter(s => s.status === 'done' || s.status === 'skipped').length; const lastTool = task.lastToolCall || '—'; const lastToolAt = task.lastToolCallAt ? new Date(task.lastToolCallAt).toLocaleTimeString() : '—'; const channel = task.channel === 'telegram' ? 'Telegram' : 'Web UI'; const startedAt = task.startedAt ? new Date(task.startedAt).toLocaleString() : '—'; const statusColor = { running:'#0d4faf', queued:'#7c4d00', paused:'#555', stalled:'#9c1a1a', needs_assistance:'#6d2d9e', complete:'#1a6e35', failed:'#9c1a1a' }; const sc = statusColor[task.status] || '#555'; // Build plan steps HTML const stepsHTML = plan.map((step, i) => { const icon = step.status === 'done' ? '✓' : step.status === 'failed' ? '✕' : step.status === 'skipped' ? '↷' : i === (task.currentStepIndex || 0) && task.status === 'running' ? '▶' : '○'; const isCurrent = i === (task.currentStepIndex || 0) && task.status !== 'complete'; return `
${icon}
${escHtml(step.description)}
${step.notes ? `
${escHtml(step.notes.slice(0,120))}
` : ''}
`; }).join(''); // Build journal HTML (last 30 entries) const recentJournal = journal.slice(-30).reverse(); const journalHTML = recentJournal.map(entry => { const time = new Date(entry.t).toLocaleTimeString(); const typeColor = { tool_call:'#0d4faf', tool_result:'#1a6e35', error:'#9c1a1a', plan_mutation:'#6d2d9e', status_push:'#555', pause:'#7c4d00', resume:'#7c4d00', advisor_decision:'#555', heartbeat:'var(--brand)' }; const tc = typeColor[entry.type] || '#888'; return `
${time} ${entry.type} ${escHtml(entry.content)}
`; }).join(''); // Build assistance block outside template literal to avoid backtick nesting issues let assistanceHTML = ''; const taskStatusNorm = String(task.status || '').trim().toLowerCase(); console.log('[BGT panel] task.status =', JSON.stringify(task.status), 'norm =', taskStatusNorm); if (taskStatusNorm === 'needs_assistance' || taskStatusNorm === 'paused' || taskStatusNorm === 'stalled' || taskStatusNorm === 'failed') { const lastPause = [...journal].reverse().find(e => e.type === 'pause' || e.type === 'error'); const lastStatusPush = [...journal].reverse().find(e => e.type === 'status_push' && e.content); const pauseMsg = escHtml((lastPause?.content || lastStatusPush?.content || 'Task paused and waiting for input.').replace(/^Task paused for assistance:\s*/i, '')); const pauseDetail = lastPause?.detail ? escHtml(lastPause.detail.slice(0, 300)) : ''; const tid = escHtml(task.id); assistanceHTML = '
' + '
⚠️ Task needs your input
' + '
' + pauseMsg + '
' + (pauseDetail ? '
' + pauseDetail + '
' : '') + '
' + '
' + '
💬 Reply to agent
' + '
' + '' + '' + '
'; } body.innerHTML = `
${STATUS_ICON[task.status]} ${task.status.replace(/_/g,' ')} ${channel} Started ${startedAt}
${task.finalSummary ? `
✓ Summary
${escHtml(task.finalSummary)}
` : ''} ${assistanceHTML}
Progress
${stepsDone}/${plan.length}
steps done
Last Tool
${escHtml(lastTool.slice(0,30))}
${lastToolAt}
${plan.length > 0 ? `
Execution Plan
${stepsHTML}
` : ''}
Process Log (latest first)
${journalHTML || '
No entries yet.
'}
${(task.status === 'complete' || task.status === 'failed') ? ` ` : ''} `; // Wire up the reply send button via JS (avoids inline onclick escaping issues) const sendBtn = document.getElementById('task-reply-send'); if (sendBtn) { sendBtn.addEventListener('click', () => bgtSendReply(sendBtn.dataset.taskid)); } // Also allow Enter (without Shift) in the textarea to send const replyInput = document.getElementById('task-reply-input'); if (replyInput) { replyInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); bgtSendReply(sendBtn ? sendBtn.dataset.taskid : bgtOpenTaskId); } }); } } // --- Actions ----------------------------------------------------------------- async function bgtPauseResume() { if (!bgtOpenTaskId) return; const task = bgtTasks.find(t => t.id === bgtOpenTaskId); if (!task) return; if (task.status === 'running') { // Pause try { await api(`/api/bg-tasks/${bgtOpenTaskId}/pause`, { method: 'POST' }); } catch {} } else if (task.status === 'paused' || task.status === 'queued') { // Resume try { await api(`/api/bg-tasks/${bgtOpenTaskId}/resume`, { method: 'POST' }); } catch {} } await refreshBgTasks(); await bgtRefreshOpenPanel(); } async function bgtSendReply(taskId) { const input = document.getElementById('task-reply-input'); if (!input) return; const message = input.value.trim(); if (!message) return; input.value = ''; input.disabled = true; try { const result = await api(`/api/bg-tasks/${taskId}/message`, { method: 'POST', body: JSON.stringify({ message }) }); if (result.success) { // Refresh the panel after a short delay to show the journal update setTimeout(() => bgtRefreshOpenPanel(), 800); } else { alert('Failed to send: ' + (result.error || 'unknown error')); } } catch (e) { alert('Error sending message: ' + e.message); } finally { input.disabled = false; input.focus(); } } async function bgtChatSend() { const input = document.getElementById('bgt-chat-input'); if (!input || !bgtOpenTaskId) return; const message = input.value.trim(); if (!message) return; input.value = ''; input.disabled = true; input.style.opacity = '0.5'; try { const result = await api(`/api/bg-tasks/${bgtOpenTaskId}/message`, { method: 'POST', body: JSON.stringify({ message }) }); if (result.success) { // Show optimistic sent indicator in journal area const body = document.getElementById('bgt-panel-body'); if (body) { const sentDiv = document.createElement('div'); sentDiv.style.cssText = 'background:var(--brand);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;align-self:flex-end;max-width:80%;word-break:break-word;opacity:0.85'; sentDiv.textContent = '\u2192 ' + message; body.appendChild(sentDiv); body.scrollTop = body.scrollHeight; } // Refresh panel after short delay to show journal update setTimeout(() => bgtRefreshOpenPanel(), 1000); } else { alert('Failed to send: ' + (result.error || 'unknown error')); } } catch (e) { alert('Error: ' + e.message); } finally { input.disabled = false; input.style.opacity = ''; input.focus(); } } async function bgtDeleteTask(taskId, status) { const isActive = status && status !== 'complete' && status !== 'failed'; const prompt = isActive ? 'This task is still active. Remove it from the board anyway?' : 'Remove this task from the board?'; if (!confirm(prompt)) return; try { await api(`/api/bg-tasks/${taskId}`, { method: 'DELETE' }); } catch {} if (bgtOpenTaskId === taskId) closeBgtPanel(); await refreshBgTasks(); } // --- Toast notification ------------------------------------------------------- function bgtToast(title, body) { const toast = document.createElement('div'); toast.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:9999;background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 16px;min-width:240px;max-width:360px;box-shadow:0 4px 24px rgba(0,0,0,0.12);font-family:var(--font)`; toast.innerHTML = `
${escHtml(title)}
${body ? `
${escHtml(body)}
` : ''}`; document.body.appendChild(toast); setTimeout(() => toast.remove(), 6000); } // --- Integrations Tab --------------------------------------------------------- let _integTab = 'webhooks'; function setIntegTab(tab) { _integTab = tab; ['webhooks', 'mcp'].forEach(t => { const btn = document.getElementById('itab-' + t); const panel = document.getElementById('itab-panel-' + t); if (btn) { btn.style.background = t === tab ? '#eaf2ff' : '#fff'; btn.style.color = t === tab ? '#0d4faf' : 'var(--muted)'; btn.style.borderColor = t === tab ? '#bdd3f6' : 'var(--line)'; } if (panel) panel.style.display = t === tab ? 'block' : 'none'; }); } async function loadIntegrationsTab() { setIntegTab(_integTab); await loadWebhookSettings(); await loadMCPServers(); } // --- Webhooks ----------------------------------------------------------------- async function loadWebhookSettings() { try { const data = await api('/api/settings/hooks'); const h = data.hooks || {}; const cb = document.getElementById('wh-enabled'); const inp = document.getElementById('wh-token'); const pathInp = document.getElementById('wh-path'); if (cb) cb.checked = h.enabled === true; if (inp) inp.value = h.tokenSet ? '••••••••' : ''; if (pathInp) pathInp.value = h.path || '/hooks'; updateWebhookStatus(h.enabled, h.tokenSet); updateWebhookUrlDisplay(h.enabled, h.tokenSet, h.path || '/hooks'); } catch(e) { console.warn('loadWebhookSettings:', e); } } function updateWebhookStatus(enabled, tokenSet) { const dot = document.getElementById('wh-status-dot'); const txt = document.getElementById('wh-status-text'); if (!dot || !txt) return; if (enabled && tokenSet) { dot.style.background = '#22c55e'; txt.textContent = '활성 — 엔드포인트 실행 중'; txt.style.color = '#166534'; } else if (!enabled) { dot.style.background = '#ccc'; txt.textContent = '비활성'; txt.style.color = 'var(--muted)'; } else { dot.style.background = '#f59e0b'; txt.textContent = '활성화됨 — 토큰 미설정'; txt.style.color = '#92400e'; } } function updateWebhookUrlDisplay(enabled, tokenSet, hookPath) { const el = document.getElementById('wh-url-display'); if (!el) return; if (enabled && tokenSet) { const origin = window.location.origin || 'http://localhost:18789'; el.style.display = 'block'; el.textContent = origin + hookPath + '/agent'; } else { el.style.display = 'none'; } } function generateWebhookToken() { const arr = new Uint8Array(24); crypto.getRandomValues(arr); const token = Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join(''); const inp = document.getElementById('wh-token'); if (inp) { inp.value = token; inp.type = 'text'; setTimeout(() => { inp.type = 'password'; }, 4000); } } async function saveWebhookSettings() { const enabled = document.getElementById('wh-enabled')?.checked ?? false; const rawToken = document.getElementById('wh-token')?.value?.trim() || ''; const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks'; try { const r = await api('/api/settings/hooks', { method: 'POST', body: JSON.stringify({ enabled, token: rawToken, path: hookPath }), }); if (r.success) { await loadWebhookSettings(); showIntegMsg('? Webhook settings saved — restart gateway to apply', '#166534', '#f0fdf4'); } else { showIntegMsg('Error: ' + (r.error || 'Unknown error'), '#991b1b', '#fef2f2'); } } catch(e) { showIntegMsg('Save failed: ' + e.message, '#991b1b', '#fef2f2'); } } async function testWebhookEndpoint() { try { const r = await api('/api/settings/hooks/test', { method: 'POST' }); if (r.success) { showIntegMsg('? ' + (r.message || 'Endpoint is active'), '#166534', '#f0fdf4'); } else { showIntegMsg('? ' + (r.error || 'Test failed'), '#991b1b', '#fef2f2'); } } catch(e) { showIntegMsg('Test failed: ' + e.message, '#991b1b', '#fef2f2'); } } function copyWebhookCurl() { const origin = window.location.origin || 'http://localhost:18789'; const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks'; const curl = `curl -X POST ${origin}${hookPath}/agent \\ -H "x-cherryclaw-token: YOUR_TOKEN" \\ -H "Content-Type: application/json" \\ -d '{"message": "Hello from webhook", "name": "MyApp", "deliver": true}'`; navigator.clipboard?.writeText(curl).then(() => { showIntegMsg('cURL example copied to clipboard', '#1e40af', '#eff6ff'); }).catch(() => { showIntegMsg('Could not copy — check browser permissions', '#92400e', '#fffbeb'); }); } // --- MCP --------------------------------------------------------------------- let _mcpEditingId = null; const MCP_PRESETS = { filesystem: { name: 'Filesystem', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-filesystem\nC:\\Users', env: '', description: 'Read/write local files' }, github: { name: 'GitHub', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-github', env: 'GITHUB_PERSONAL_ACCESS_TOKEN=', description: 'Repos, PRs, issues' }, postgres: { name: 'PostgreSQL', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-postgres\npostgresql://localhost/mydb', env: '', description: 'Query PostgreSQL' }, sqlite: { name: 'SQLite', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-sqlite\n--db-path\nC:\\path\\to\\db.sqlite', env: '', description: 'Local database' }, brave: { name: 'Brave Search', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-brave-search', env: 'BRAVE_API_KEY=', description: 'Web search' }, memory: { name: 'Memory', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-memory', env: '', description: 'Persistent knowledge store' }, }; async function loadMCPServers() { const el = document.getElementById('mcp-server-list'); if (!el) return; try { const data = await api('/api/mcp/servers'); const servers = data.servers || []; if (servers.length === 0) { el.innerHTML = '
No MCP servers configured yet.
Add one above or click a preset on the right.
'; return; } el.innerHTML = servers.map(s => { const statusColor = s.status === 'connected' ? '#22c55e' : s.status === 'error' ? '#ef4444' : s.status === 'connecting' ? '#f59e0b' : '#9ca3af'; const statusLabel = s.status === 'connected' ? `Connected · ${s.toolCount} tool${s.toolCount !== 1 ? 's' : ''}` : s.status === 'error' ? 'Error' : s.status === 'connecting' ? 'Connecting…' : 'Disconnected'; const toolTip = s.toolNames?.length ? s.toolNames.join(', ') : ''; return `
${escHtml(s.name)} (${escHtml(s.id)})
${statusLabel}${s.error ? ` — ${escHtml(s.error.slice(0,60))}` : ''}
${toolTip ? `
${escHtml(toolTip.slice(0,80))}${toolTip.length>80?'…':''}
` : ''}
${s.status === 'connected' ? `` : `` }
`; }).join(''); } catch(e) { el.innerHTML = `
Failed to load: ${escHtml(e.message)}
`; } } function showMCPAddForm(editData) { _mcpEditingId = editData?.id || null; document.getElementById('mcp-add-form').style.display = 'block'; document.getElementById('mcp-form-title').textContent = _mcpEditingId ? 'MCP 서버 편집' : 'MCP 서버 추가'; document.getElementById('mcp-form-msg').textContent = ''; if (editData) { const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; }; v('mcp-f-id', editData.id); v('mcp-f-name', editData.name); document.getElementById('mcp-f-transport').value = editData.transport || 'stdio'; onMCPTransportChange(); v('mcp-f-command', editData.command || ''); v('mcp-f-args', (editData.args || []).join('\n')); v('mcp-f-env', Object.entries(editData.env || {}).map(([k,v]) => `${k}=${v}`).join('\n')); v('mcp-f-url', editData.url || ''); v('mcp-f-headers', Object.entries(editData.headers || {}).map(([k,v]) => `${k}: ${v}`).join('\n')); const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = editData.enabled !== false; } else { ['mcp-f-id','mcp-f-name','mcp-f-command','mcp-f-args','mcp-f-env','mcp-f-url','mcp-f-headers'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); document.getElementById('mcp-f-transport').value = 'stdio'; onMCPTransportChange(); const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = true; } document.getElementById('mcp-f-id').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function hideMCPAddForm() { document.getElementById('mcp-add-form').style.display = 'none'; _mcpEditingId = null; } function onMCPTransportChange() { const t = document.getElementById('mcp-f-transport')?.value; document.getElementById('mcp-stdio-fields').style.display = t === 'stdio' ? 'block' : 'none'; document.getElementById('mcp-sse-fields').style.display = t === 'sse' ? 'block' : 'none'; } function prefillMCPServer(presetKey) { const p = MCP_PRESETS[presetKey]; if (!p) return; showMCPAddForm(); const v = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; v('mcp-f-id', presetKey); v('mcp-f-name', p.name); document.getElementById('mcp-f-transport').value = p.transport; onMCPTransportChange(); v('mcp-f-command', p.command || ''); v('mcp-f-args', p.args || ''); v('mcp-f-env', p.env || ''); v('mcp-f-url', p.url || ''); setIntegTab('mcp'); } async function saveMCPServer() { const id = document.getElementById('mcp-f-id')?.value?.trim(); const name = document.getElementById('mcp-f-name')?.value?.trim(); const transport = document.getElementById('mcp-f-transport')?.value || 'stdio'; const enabled = document.getElementById('mcp-f-enabled')?.checked ?? true; const msgEl = document.getElementById('mcp-form-msg'); if (!id || !name) { if (msgEl) msgEl.textContent = 'ID와 이름은 필수입니다.'; return; } const cfg = { id, name, transport, enabled }; if (transport === 'stdio') { cfg.command = document.getElementById('mcp-f-command')?.value?.trim() || ''; const argsRaw = document.getElementById('mcp-f-args')?.value || ''; cfg.args = argsRaw.split('\n').map(s => s.trim()).filter(Boolean); const envRaw = document.getElementById('mcp-f-env')?.value || ''; cfg.env = {}; envRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => { const eq = line.indexOf('='); if (eq > 0) cfg.env[line.slice(0,eq).trim()] = line.slice(eq+1); }); } else { cfg.url = document.getElementById('mcp-f-url')?.value?.trim() || ''; const headersRaw = document.getElementById('mcp-f-headers')?.value || ''; cfg.headers = {}; headersRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => { const colon = line.indexOf(':'); if (colon > 0) cfg.headers[line.slice(0,colon).trim()] = line.slice(colon+1).trim(); }); } try { const r = await api('/api/mcp/servers', { method: 'POST', body: JSON.stringify(cfg) }); if (r.success) { if (msgEl) msgEl.textContent = '? Saved'; setTimeout(() => hideMCPAddForm(), 600); await loadMCPServers(); // Auto-connect if enabled if (enabled) connectMCPServer(id); } else { if (msgEl) msgEl.textContent = '오류: ' + (r.error || '알 수 없음'); } } catch(e) { if (msgEl) msgEl.textContent = '저장 실패: ' + e.message; } } async function editMCPServer(id) { try { const data = await api('/api/mcp/servers'); const server = (data.servers || []).find(s => s.id === id); if (server) showMCPAddForm(server); } catch(e) { console.warn('editMCPServer:', e); } } async function deleteMCPServer(id) { if (!confirm(`Delete MCP server "${id}"?`)) return; try { await api(`/api/mcp/servers/${id}`, { method: 'DELETE' }); await loadMCPServers(); } catch(e) { showIntegMsg('Delete failed: ' + e.message, '#991b1b', '#fef2f2'); } } async function connectMCPServer(id) { showIntegMsg('Connecting to ' + id + '…', '#1e40af', '#eff6ff'); try { const r = await api(`/api/mcp/servers/${id}/connect`, { method: 'POST' }); if (r.success) { showIntegMsg(`? Connected — ${(r.tools||[]).length} tool(s) available`, '#166534', '#f0fdf4'); } else { showIntegMsg('? Connection failed: ' + (r.error || 'Unknown'), '#991b1b', '#fef2f2'); } await loadMCPServers(); } catch(e) { showIntegMsg('Connect error: ' + e.message, '#991b1b', '#fef2f2'); await loadMCPServers(); } } async function disconnectMCPServer(id) { try { await api(`/api/mcp/servers/${id}/disconnect`, { method: 'POST' }); await loadMCPServers(); showIntegMsg('Disconnected from ' + id, '#475569', '#f8fafc'); } catch(e) { showIntegMsg('Disconnect error: ' + e.message, '#991b1b', '#fef2f2'); } } // --- Shared helper ------------------------------------------------------------ function showIntegMsg(msg, color, bg) { let el = document.getElementById('integ-toast'); if (!el) { el = document.createElement('div'); el.id = 'integ-toast'; el.style.cssText = 'position:fixed;bottom:80px;right:24px;z-index:9999;padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,0.12);transition:opacity .3s'; document.body.appendChild(el); } el.style.background = bg || '#f0fdf4'; el.style.color = color || '#166534'; el.style.border = `1px solid ${color || '#166534'}33`; el.style.opacity = '1'; el.textContent = msg; clearTimeout(el._timeout); el._timeout = setTimeout(() => { el.style.opacity = '0'; }, 3500); } // ─────────────────────────────────────────────────────────────────────────── // ERROR RESPONSE SYSTEM — Full JavaScript Implementation // Handles: show/hide panel, option selection, input rendering, // credential entry, form submission, WS event binding // ─────────────────────────────────────────────────────────────────────────── // State let _errTaskId = null; let _errTemplate = null; let _errSelectedAction = null; let _errCategory = null; let _errBackdrop = null; function _getOrCreateErrorBackdrop() { if (_errBackdrop) return _errBackdrop; _errBackdrop = document.createElement('div'); _errBackdrop.id = 'error-response-backdrop'; _errBackdrop.style.cssText = 'position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9997;display:none'; _errBackdrop.addEventListener('click', closeErrorResponse); document.body.appendChild(_errBackdrop); return _errBackdrop; } function showErrorResponsePanel(taskId, category, errorMessage, errorDetail, template) { _errTaskId = taskId; _errTemplate = template; _errSelectedAction = null; _errCategory = category; // Title & description const titleEl = document.getElementById('error-response-title'); const descEl = document.getElementById('error-response-desc'); if (titleEl) titleEl.textContent = template?.title || ('\u26a0\ufe0f ' + (category || 'Error').toUpperCase()); if (descEl) descEl.textContent = template?.description || errorMessage || 'The task needs your help to continue.'; // Render options const optionsEl = document.getElementById('error-response-options'); if (optionsEl) { optionsEl.innerHTML = ''; const options = template?.options || [ { id: 'retry_now', label: '\u27f3 Retry Now', icon: '\u27f3' }, { id: 'skip', label: '\u229a Skip Step', icon: '\u229a' }, { id: 'cancel', label: '\u2715 Cancel Task', icon: '\u2715', danger: true }, ]; options.forEach(opt => { const btn = document.createElement('button'); btn.className = 'error-option-btn' + (opt.danger ? ' danger' : ''); btn.dataset.actionId = opt.id; btn.innerHTML = `${escHtml(opt.icon || '')} ${escHtml(opt.label)}${opt.description ? `
${escHtml(opt.description)}` : ''}`; btn.addEventListener('click', () => selectErrorOption(opt)); optionsEl.appendChild(btn); }); } // Clear inputs const inputsEl = document.getElementById('error-response-inputs'); if (inputsEl) { inputsEl.innerHTML = ''; inputsEl.classList.remove('visible'); } // Reset submit button const submitBtn = document.getElementById('error-response-submit-btn'); if (submitBtn) submitBtn.disabled = true; // Show backdrop + panel const backdrop = _getOrCreateErrorBackdrop(); backdrop.style.display = 'block'; const panel = document.getElementById('error-response-panel'); if (panel) panel.classList.add('visible'); // Notification toast bgtToast('\u26a0\ufe0f Task needs help', (template?.title || category || 'Error') + ' — see panel'); } function selectErrorOption(opt) { _errSelectedAction = opt.id; // Highlight selected document.querySelectorAll('.error-option-btn').forEach(b => b.classList.remove('selected')); const btn = document.querySelector(`.error-option-btn[data-action-id="${opt.id}"]`); if (btn) btn.classList.add('selected'); // Show input fields if this option requires them const inputsEl = document.getElementById('error-response-inputs'); if (inputsEl) { inputsEl.innerHTML = ''; inputsEl.classList.remove('visible'); const triggerInputs = opt.triggerInputs || []; const allFields = _errTemplate?.requiredInputs || []; const fieldsToShow = triggerInputs.length > 0 ? allFields.filter(f => triggerInputs.includes(f.id)) : []; if (fieldsToShow.length > 0) { fieldsToShow.forEach(field => { const wrap = document.createElement('div'); wrap.className = 'input-field'; const label = document.createElement('label'); label.textContent = field.label; label.setAttribute('for', 'err-input-' + field.id); const input = document.createElement('input'); input.id = 'err-input-' + field.id; input.type = field.type || 'text'; input.placeholder = field.placeholder || ''; input.dataset.fieldId = field.id; if (field.validation === 'digits_only') { input.inputMode = 'numeric'; input.pattern = '[0-9]*'; input.maxLength = 8; } wrap.appendChild(label); wrap.appendChild(input); inputsEl.appendChild(wrap); }); inputsEl.classList.add('visible'); // Focus first input const firstInput = inputsEl.querySelector('input'); if (firstInput) setTimeout(() => firstInput.focus(), 80); } } // Enable submit unless it's cancel (which submits immediately) const submitBtn = document.getElementById('error-response-submit-btn'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = opt.danger ? '작업 취소' : '계속'; } } function closeErrorResponse() { _errTaskId = null; _errTemplate = null; _errSelectedAction = null; _errCategory = null; const backdrop = document.getElementById('error-response-backdrop'); if (backdrop) backdrop.style.display = 'none'; const panel = document.getElementById('error-response-panel'); if (panel) panel.classList.remove('visible'); } async function submitErrorResponse() { if (!_errTaskId || !_errSelectedAction) return; const action = _errSelectedAction; const category = _errCategory; // Collect input values const inputs = {}; document.querySelectorAll('#error-response-inputs input[data-field-id]').forEach(el => { if (el.value.trim()) inputs[el.dataset.fieldId] = el.value.trim(); }); // Validate required fields for credential action if (action === 'credentials') { if (!inputs.email) { const emailEl = document.getElementById('err-input-email'); if (emailEl) { emailEl.style.borderColor = 'var(--err)'; emailEl.focus(); } return; } if (!inputs.password) { const pwEl = document.getElementById('err-input-password'); if (pwEl) { pwEl.style.borderColor = 'var(--err)'; pwEl.focus(); } return; } } if (action === 'verification_code') { if (!inputs.code) { const codeEl = document.getElementById('err-input-code'); if (codeEl) { codeEl.style.borderColor = 'var(--err)'; codeEl.focus(); } return; } } // Disable submit to prevent double-send const submitBtn = document.getElementById('error-response-submit-btn'); if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '전송 중…'; } try { const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ action, category, inputs }), }); const data = await resp.json(); if (data.success) { closeErrorResponse(); if (data.resumed) { bgtToast('\u2705 Task resuming', 'Agent is continuing with your response'); // Refresh the kanban board to show updated status if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 800); } else { bgtToast('Task cancelled', 'Task has been stopped'); if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 400); } } else { bgtToast('\u274c Error', data.error || 'Failed to submit response'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; } } } catch (err) { console.error('[ErrorResponse] Submit failed:', err); bgtToast('\u274c Network error', 'Could not submit response'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; } } } // Hook into the existing WebSocket message handler // We extend the onmessage handler after the page loads (function patchWebSocketForErrorResponse() { function applyPatch() { // Find the ws variable — it’s declared in a closure, so we intercept via MutationObserver // Alternative: override addEventListener on WebSocket prototype to intercept messages const _origOnMessage = WebSocket.prototype.__defineGetter__ ? null : null; // not needed — use event listener approach below // We hook into the document-level custom event that server-v2’s WS handler // already dispatches. If that’s not available, we’ll patch the WS directly. document.addEventListener('ws:message', handleErrorResponseWsEvent); // Also directly patch any open WebSocket connections by overriding WebSocket prototype const _OrigWS = window.WebSocket; window.WebSocket = function(...args) { const ws = new _OrigWS(...args); const _origAddEventListener = ws.addEventListener.bind(ws); ws.addEventListener = function(type, listener, ...rest) { if (type === 'message') { const wrappedListener = function(e) { try { const msg = JSON.parse(e.data); if (msg.type === 'task_error_requires_response') { handleErrorResponseWsEvent({ detail: msg }); } } catch {} return listener.call(this, e); }; return _origAddEventListener('message', wrappedListener, ...rest); } return _origAddEventListener(type, listener, ...rest); }; // Also patch onmessage setter let _onmsgHandler = null; Object.defineProperty(ws, 'onmessage', { get: () => _onmsgHandler, set: (fn) => { _onmsgHandler = fn; _origAddEventListener('message', (e) => { try { const msg = JSON.parse(e.data); if (msg.type === 'task_error_requires_response') { handleErrorResponseWsEvent({ detail: msg }); } } catch {} if (_onmsgHandler) _onmsgHandler.call(ws, e); }); }, configurable: true, }); return ws; }; // Copy static properties Object.assign(window.WebSocket, _OrigWS); window.WebSocket.prototype = _OrigWS.prototype; window.WebSocket.CONNECTING = _OrigWS.CONNECTING; window.WebSocket.OPEN = _OrigWS.OPEN; window.WebSocket.CLOSING = _OrigWS.CLOSING; window.WebSocket.CLOSED = _OrigWS.CLOSED; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', applyPatch); } else { applyPatch(); } })(); // ── Mobile tab navigation ─────────────────────────────────────────────────── function mobTab(tab) { const body = document.body; if (tab === 'menu') { const isOpen = body.classList.contains('mob-menu'); if (isOpen) { mobCloseMenu(); } else { body.classList.add('mob-menu'); } // Keep chat/ctx tab highlight unchanged; toggle menu highlight document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen); return; } mobCloseMenu(); ['chat', 'ctx', 'menu'].forEach(t => document.getElementById('mob-tab-' + t)?.classList.toggle('active', t === tab) ); if (tab === 'ctx') { body.classList.add('mob-ctx'); } else { body.classList.remove('mob-ctx'); } } function mobCloseMenu() { document.body.classList.remove('mob-menu'); document.getElementById('mob-backdrop').style.display = 'none'; document.getElementById('mob-tab-menu')?.classList.remove('active'); } // ── Right-panel collapse ──────────────────────────────────────────────────── const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed'; function applyRightPanelCollapsed(collapsed) { document.body.classList.toggle('right-collapsed', !!collapsed); // Only override on desktop — narrow viewports use the mobile single-column layout. const isDesktop = window.matchMedia('(min-width: 980px)').matches; if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; } if (collapsed) { document.body.style.gridTemplateColumns = '280px 1fr 36px'; } else { const saved = parseInt(localStorage.getItem('rightPanelWidth'), 10); const max = Math.floor(window.innerWidth * 0.65); const w = saved && saved >= 280 ? Math.min(saved, max) : ''; document.body.style.gridTemplateColumns = w ? `280px 1fr ${w}px` : ''; } } function toggleRightPanel() { const next = !document.body.classList.contains('right-collapsed'); localStorage.setItem(RIGHT_COLLAPSE_KEY, next ? '1' : '0'); applyRightPanelCollapsed(next); } // Default to collapsed when no preference saved. (function initRightPanelCollapse() { const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY); const collapsed = stored === null ? true : stored === '1'; applyRightPanelCollapsed(collapsed); })(); // ── Right-panel resize ────────────────────────────────────────────────────── (function initRightPanelResize() { const MIN_W = 280; const MAX_W_RATIO = 0.65; const STORE_KEY = 'rightPanelWidth'; function applyWidth(px) { document.body.style.gridTemplateColumns = `280px 1fr ${px}px`; } function clamp(px) { return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px)); } // Restore saved width (only if not collapsed) if (!document.body.classList.contains('right-collapsed')) { const saved = parseInt(localStorage.getItem(STORE_KEY), 10); if (saved && saved >= MIN_W) applyWidth(clamp(saved)); } function setup() { const handle = document.getElementById('right-panel-resize-handle'); const panel = document.getElementById('right-panel'); if (!handle || !panel) return; handle.addEventListener('mouseenter', () => { handle.style.background = 'var(--brand, #1668e3)'; handle.style.opacity = '0.35'; }); handle.addEventListener('mouseleave', () => { handle.style.background = 'transparent'; handle.style.opacity = '1'; }); handle.addEventListener('mousedown', (e) => { e.preventDefault(); const startX = e.clientX; const startW = panel.getBoundingClientRect().width; // Prevent iframes from swallowing events during drag document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none'); document.body.style.userSelect = 'none'; handle.style.background = 'var(--brand, #1668e3)'; handle.style.opacity = '0.6'; function onMove(ev) { const newW = clamp(startW + (startX - ev.clientX)); applyWidth(newW); } function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = ''); document.body.style.userSelect = ''; handle.style.background = 'transparent'; handle.style.opacity = '1'; const finalW = panel.getBoundingClientRect().width; localStorage.setItem(STORE_KEY, Math.round(finalW)); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup); else setup(); })(); function handleErrorResponseWsEvent(e) { const msg = e.detail || e; if (!msg || msg.type !== 'task_error_requires_response') return; showErrorResponsePanel( msg.taskId, msg.errorCategory, msg.errorMessage, msg.errorDetail, msg.template, ); } // ── Code editor stubs (editor moved to code.html) ──────────────────────── function v2LiveCodeUpdate() {} function codeEditorWriteFile() {} // ── Terminal infrastructure (PTY over WebSocket) ───────────────────────── let termWs = null; let termWsConnected = false; function termWsConnect() { if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; let url = `${proto}//${location.host}/ws`; const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session'); if (token) url += `?token=${encodeURIComponent(token)}`; try { termWs = new WebSocket(url); } catch { return; } termWs.onopen = () => { termWsConnected = true; for (const [shellId, inst] of xtermInstances) { if (!inst.ptyReady) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); } } }; termWs.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write(msg.data); } else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); } } else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); } }; termWs.onclose = () => { termWsConnected = false; termWs = null; for (const [,i] of xtermInstances) i.ptyReady = false; setTimeout(termWsConnect, 3000); }; termWs.onerror = () => { termWsConnected = false; }; } function termWsSend(msg) { if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; } return false; } const xtermInstances = new Map(); function createXtermShell(container, shellId) { const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8); const sub = document.createElement('div'); sub.id = 'xterm-' + id; sub.style.cssText = 'position:absolute;inset:0'; container.appendChild(sub); const term = new Terminal({ theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e', selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C', yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC', brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' }, fontFamily: "'IBM Plex Mono','Courier New',monospace", fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000, }); const fitAddon = new FitAddon.FitAddon(); const webLinksAddon = new WebLinksAddon.WebLinksAddon(); term.loadAddon(fitAddon); term.loadAddon(webLinksAddon); term.open(sub); fitAddon.fit(); const inst = { term, fitAddon, ptyReady: false }; xtermInstances.set(id, inst); term.onData(data => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data }); }); term.onResize(({ cols, rows }) => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); }); if (termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); } return { id, term, fitAddon, inst }; } // Init WebSocket connection on load fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect); // ── Chat terminal (for chat view) ───────────────────────────────────────── let chatTermShells = []; let activeChatTermShellId = null; let chatTermOpen = false; let chatTermHeight = 280; function toggleChatTerminal() { const panel = document.getElementById('chat-terminal-panel'); const btn = document.getElementById('chat-term-btn'); if (!panel) return; chatTermOpen = !chatTermOpen; if (chatTermOpen) { panel.style.display = 'flex'; if (btn) btn.classList.add('active'); if (!chatTermShells.length) { addChatTermShell(); } else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); } } else { panel.style.display = 'none'; if (btn) btn.classList.remove('active'); } } function renderChatShellTabs() { const bar = document.getElementById('chat-shell-tabs'); if (!bar) return; bar.innerHTML = chatTermShells.map(s => { const a = s.id === activeChatTermShellId; return `
${escHtml(s.name)}${chatTermShells.length>1?`×`:''}
`; }).join(''); } function addChatTermShell() { const id = 'ct_' + Math.random().toString(36).slice(2,8); chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null }); switchChatTermShell(id); } function switchChatTermShell(id) { const cur = chatTermShells.find(s => s.id === activeChatTermShellId); if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; } activeChatTermShellId = id; const shell = chatTermShells.find(s => s.id === id); if (!shell) return; if (shell.term) { const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display=''; if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus(); } else { const container = document.getElementById('chat-term-container'); if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); } } renderChatShellTabs(); } function closeChatTermShell(id, ev) { if (ev) ev.stopPropagation(); const idx = chatTermShells.findIndex(s => s.id === id); if (idx === -1 || chatTermShells.length <= 1) return; termWsSend({ type:'pty_kill', shellId:id }); const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose(); xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove(); chatTermShells.splice(idx, 1); if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id); else renderChatShellTabs(); } document.addEventListener('DOMContentLoaded', function() { const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return; let startY=0, startH=0; handle.addEventListener('mousedown', function(e) { startY=e.clientY; startH=chatTermHeight; const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); }); }); // ── PPTX Workflow Sidebar ───────────────────────────────────────────────────── let _pptxImages = []; // {url, thumb, label} let _pptxOutline = null; // parsed pptx-outline JSON let _pptxOutlineEdits = {}; // {index: {title, bullets}} function setPptxSbTab(tab) { ['setup','images','outline'].forEach(t => { document.getElementById('psb-tab-' + t).classList.toggle('active', t === tab); const el = document.getElementById('psb-' + t); if (el) el.style.display = t === tab ? (t === 'outline' ? 'flex' : (t === 'images' ? 'block' : 'flex')) : 'none'; }); } function pptxSetStatus(msg) { const el = document.getElementById('psb-status'); if (el) el.textContent = msg; } function pptxStartWorkflow() { const topic = (document.getElementById('psb-topic').value || '').trim(); const pmcidsRaw = (document.getElementById('psb-pmcids').value || '').trim(); const template = document.getElementById('psb-template').value || 'dark'; const slideCount = parseInt(document.getElementById('psb-slide-count').value) || 0; if (!topic) { pptxSetStatus('주제를 입력하세요.'); return; } const pmcids = pmcidsRaw .split(/[\n,]+/) .map(s => s.trim().replace(/^PMC/i, 'PMC')) .filter(Boolean); _pptxImages = []; _pptxOutline = null; _pptxOutlineEdits = {}; pptxRenderImageGrid(); const cardsEl = document.getElementById('psb-outline-cards'); if (cardsEl) cardsEl.innerHTML = '
아웃라인 생성 중...
'; const genBtn = document.getElementById('psb-gen-btn'); if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; } // Build workflow message const pmcLine = pmcids.length ? `\nPMCIDs: ${pmcids.join(', ')}` : ''; const slideLine = slideCount ? `\n슬라이드 수: ${slideCount}장` : ''; const msg = `[PPTX 워크플로 시작]\n주제: ${topic}${pmcLine}\n템플릿: ${template}${slideLine}\n\n아래 순서로 진행해주세요:\n1. 작업 폴더 생성 (주제 기반)\n2. PDF 다운로드 (pubmed_fulltext, 위 PMCIDs)\n3. 이미지 추출 (pdf_extract_images, 각 PDF)\n4. 소형 이미지 필터링 (python_eval)\n5. 슬라이드 구성을 \`\`\`pptx-outline 블록으로 출력 (JSON 형식)\n6. 확인 대기 (create_presentation은 아직 호출하지 마세요)`; pptxSetStatus('워크플로 시작...'); setSidebarTab('pptx'); setPptxSbTab('setup'); // Send via main chat const input = document.getElementById('chat-input'); if (input) { input.value = msg; sendChat(); } } function pptxCaptureImages(toolResultText) { // Parse image links from pdf_extract_images output: [label](/api/files/...) const re = /\[([^\]]*)\]\((\/api\/files\/[^)]+\.(?:png|jpg|jpeg|gif|webp))\)/gi; let m; while ((m = re.exec(toolResultText)) !== null) { const label = m[1]; const url = m[2]; if (!_pptxImages.some(i => i.url === url)) { _pptxImages.push({ url, label }); } } if (_pptxImages.length) { pptxRenderImageGrid(); const countEl = document.getElementById('psb-img-count'); if (countEl) countEl.textContent = `(${_pptxImages.length})`; // Auto-switch to images tab if PPTX tab is active if (sidebarTab === 'pptx') setPptxSbTab('images'); pptxSetStatus(`이미지 ${_pptxImages.length}개 추출됨`); } } function pptxRenderImageGrid() { const grid = document.getElementById('psb-image-grid'); if (!grid) return; if (!_pptxImages.length) { grid.innerHTML = '
이미지 추출 후 표시됩니다
'; return; } grid.innerHTML = _pptxImages.map((img, idx) => `
${escHtml(img.label)}
`).join(''); } function pptxImageClick(idx) { const img = _pptxImages[idx]; if (!img) return; window.open(img.url, '_blank', 'noopener'); } function pptxSetOutline(data) { _pptxOutline = data; _pptxOutlineEdits = {}; pptxRenderOutlineCards(); const genBtn = document.getElementById('psb-gen-btn'); if (genBtn) { genBtn.disabled = false; genBtn.style.opacity = '1'; } if (sidebarTab === 'pptx') setPptxSbTab('outline'); pptxSetStatus(`아웃라인 준비 (${(data.slides || []).length}장)`); } const SLIDE_TYPE_ICON = { title:'🏷', section:'📂', content:'📝', image:'🖼', table:'📊', chart:'📈', timeline:'⏳', compare:'⚖' }; function pptxRenderOutlineCards() { const container = document.getElementById('psb-outline-cards'); if (!container || !_pptxOutline) return; const slides = _pptxOutline.slides || []; if (!slides.length) { container.innerHTML = '
슬라이드가 없습니다
'; return; } container.innerHTML = slides.map((s, i) => { const icon = SLIDE_TYPE_ICON[s.type] || '📄'; const imgThumb = s.image_url ? `` : `
🖼
`; const bulletsPreview = Array.isArray(s.bullets) && s.bullets.length ? `
${s.bullets.slice(0,2).map(b => `• ${b}`).join('
')}
` : ''; return `
${i+1} ${imgThumb}
${icon} ${escHtml(s.title || '(제목 없음)')}
${s.type}
${bulletsPreview}
`; }).join(''); } function pptxToggleEdit(idx) { const el = document.getElementById('psb-edit-' + idx); if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none'; } function pptxEditTitle(idx, val) { if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {}; _pptxOutlineEdits[idx].title = val; } function pptxEditBullets(idx, val) { if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {}; _pptxOutlineEdits[idx].bullets = val.split(/\n\n+/).map(g => g.replace(/^\n+|\n+$/g, '')).filter(g => g.trim() !== ''); } function pptxGenerate() { if (!_pptxOutline) return; // Apply edits to outline const slides = (_pptxOutline.slides || []).map((s, i) => { const ed = _pptxOutlineEdits[i]; if (!ed) return s; return Object.assign({}, s, ed.title !== undefined ? { title: ed.title } : {}, ed.bullets !== undefined ? { bullets: ed.bullets } : {}); }); const edited = Object.assign({}, _pptxOutline, { slides }); const hasEdits = Object.keys(_pptxOutlineEdits).length > 0; const msg = hasEdits ? `아웃라인을 수정했습니다. 수정된 아웃라인으로 create_presentation을 호출해주세요:\n\`\`\`json\n${JSON.stringify(edited, null, 2)}\n\`\`\`` : `아웃라인 확인했습니다. create_presentation으로 슬라이드를 생성해주세요.`; const genBtn = document.getElementById('psb-gen-btn'); if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; } pptxSetStatus('생성 중...'); const input = document.getElementById('chat-input'); if (input) { input.value = msg; sendChat(); } } function pptxResetWorkflow() { _pptxImages = []; _pptxOutline = null; _pptxOutlineEdits = {}; pptxRenderImageGrid(); const cardsEl = document.getElementById('psb-outline-cards'); if (cardsEl) cardsEl.innerHTML = '
아웃라인 생성 후 표시됩니다
'; const genBtn = document.getElementById('psb-gen-btn'); if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; } const countEl = document.getElementById('psb-img-count'); if (countEl) countEl.textContent = ''; pptxSetStatus(''); setPptxSbTab('setup'); }