Files
homeclaw/web-ui/app.js
T
kimandClaude Sonnet 4.6 d74614cee1 v3.2.0: 카카오톡 연동 + 탐정앱 업그레이드 + 모바일 대응 강화
- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회)
- 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts)
- 채널 설정 UI + userMap으로 다중 사용자 계정 매핑
- 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성
- 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응)
- 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000)
- 교통앱 마지막 선택 지역 localStorage 저장/복원
- docs/kakao-setup.md 작성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:15:27 +09:00

9296 lines
414 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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); t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; }
function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; }
function generateSessionId() {
return (crypto?.randomUUID?.() || ('sess_' + Math.random().toString(36).slice(2)));
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
function setAgentSessionId(id) {
const next = String(id || '').trim() || generateSessionId();
agentSessionId = next;
localStorage.setItem(agentSessionKey(), next);
return next;
}
let agentSessionId = '';
let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication on page load
initTTSToggle();
initMicAutosendToggle();
// ── Server log panel ──────────────────────────────────────────────────────────
let logScrollLocked = true;
let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size
// 빈 Set = 전체 표시 / 값 있으면 해당 레벨만 표시
const logLevelFilter = new Set();
const logLevelCounts = { log: 0, warn: 0, error: 0 };
function toggleLogFilter(level) {
if (logLevelFilter.has(level)) {
logLevelFilter.delete(level);
} else {
logLevelFilter.add(level);
}
applyLogFilters();
}
function setAllLogFilters(on) {
logLevelFilter.clear();
if (!on) logLevelFilter.add('__none__');
applyLogFilters();
}
function applyLogFilters() {
const body = document.getElementById('sidebar-log-body');
if (body) {
const none = logLevelFilter.has('__none__');
const filtered = !none && logLevelFilter.size > 0;
body.classList.toggle('filter-active', filtered || none);
['log', 'warn', 'error'].forEach(lv => {
body.classList.toggle('hide-' + lv, none || (filtered && !logLevelFilter.has(lv)));
});
// 필터 결과 빈 경우 안내 메시지
let emptyEl = body.querySelector('.log-empty-hint');
const none2 = logLevelFilter.has('__none__');
const filtered2 = !none2 && logLevelFilter.size > 0;
const visibleCount = none2 ? 0 : filtered2
? [...logLevelFilter].reduce((s, lv) => s + (logLevelCounts[lv] || 0), 0)
: logLevelCounts.log + logLevelCounts.warn + logLevelCounts.error;
if (visibleCount === 0 && (none2 || filtered2)) {
if (!emptyEl) {
emptyEl = document.createElement('div');
emptyEl.className = 'log-empty-hint';
emptyEl.style.cssText = 'padding:12px;text-align:center;color:var(--muted);font-size:11px;';
body.appendChild(emptyEl);
}
emptyEl.textContent = none2 ? '필터: 없음' : `해당 레벨 메시지 없음 (${[...logLevelFilter].map(l=>l.toUpperCase()).join(', ')})`;
} else if (emptyEl) {
emptyEl.remove();
}
}
// 버튼: 활성 상태 + 카운트
document.querySelectorAll('.log-filter-btn[data-level]').forEach(btn => {
const lv = btn.dataset.level;
const none = logLevelFilter.has('__none__');
const filtered = !none && logLevelFilter.size > 0;
btn.classList.toggle('active', !none && (!filtered || logLevelFilter.has(lv)));
const cnt = logLevelCounts[lv] || 0;
btn.title = `${lv.toUpperCase()}: ${cnt}건`;
});
const allBtn = document.querySelector('.log-filter-all');
const noneBtn = document.querySelector('.log-filter-none');
if (allBtn) allBtn.classList.toggle('active', logLevelFilter.size === 0);
if (noneBtn) noneBtn.classList.toggle('active', logLevelFilter.has('__none__'));
}
let logSearchQuery = '';
function onLogSearch(q) {
logSearchQuery = q.trim().toLowerCase();
const clearBtn = document.getElementById('log-search-clear');
const countEl = document.getElementById('log-search-count');
if (clearBtn) clearBtn.style.display = logSearchQuery ? '' : 'none';
const body = document.getElementById('sidebar-log-body');
if (!body) return;
const lines = body.querySelectorAll('.log-line');
let matchCount = 0;
lines.forEach(line => {
const msgEl = line.querySelector('.log-msg');
const tsEl = line.querySelector('.log-ts');
if (!msgEl) return;
if (!logSearchQuery) {
// 검색 없음: 원래 텍스트 복원, no-match 제거
line.classList.remove('no-match');
if (msgEl._origText !== undefined) {
msgEl.textContent = msgEl._origText;
delete msgEl._origText;
}
return;
}
const orig = msgEl._origText !== undefined ? msgEl._origText : msgEl.textContent;
msgEl._origText = orig;
const tsText = tsEl ? tsEl.textContent : '';
const haystack = (orig + ' ' + tsText).toLowerCase();
const matches = haystack.includes(logSearchQuery);
line.classList.toggle('no-match', !matches);
if (matches) {
matchCount++;
// 하이라이트: 대소문자 무시 치환
const re = new RegExp(logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
msgEl.innerHTML = orig.replace(re, m => `<span class="log-hl">${m}</span>`);
} else {
msgEl.textContent = orig;
}
});
if (countEl) {
if (logSearchQuery) {
countEl.textContent = `${matchCount}건`;
countEl.style.display = '';
countEl.style.color = matchCount === 0 ? '#ef4444' : 'var(--muted)';
} else {
countEl.style.display = 'none';
}
}
}
function toggleLogPanel() {
const panel = document.getElementById('sidebar-log-panel');
if (!panel) return;
panel.classList.toggle('collapsed');
localStorage.setItem('logPanelCollapsed', panel.classList.contains('collapsed') ? '1' : '0');
}
function toggleLogScrollLock() {
logScrollLocked = !logScrollLocked;
const btn = document.getElementById('log-scroll-lock-btn');
if (btn) btn.style.opacity = logScrollLocked ? '1' : '0.4';
}
function clearLogPanel() {
const body = document.getElementById('sidebar-log-body');
if (body) body.innerHTML = '';
logLastTotalCount = 0;
logLevelCounts.log = 0; logLevelCounts.warn = 0; logLevelCounts.error = 0;
const inp = document.getElementById('log-search-input');
if (inp) inp.value = '';
logSearchQuery = '';
applyLogFilters();
onLogSearch('');
}
function renderLogEntries(entries) {
const body = document.getElementById('sidebar-log-body');
if (!body) return;
const frag = document.createDocumentFragment();
for (const e of entries) {
const lv = e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : 'log';
logLevelCounts[lv] = (logLevelCounts[lv] || 0) + 1;
const lvCls = lv === 'error' ? 'error lv-error' : lv === 'warn' ? 'warn lv-warn' : 'lv-log';
const line = document.createElement('div');
line.className = 'log-line ' + lvCls;
const ts = document.createElement('span');
ts.className = 'log-ts';
ts.textContent = e.ts;
const msg = document.createElement('span');
msg.className = 'log-msg';
msg.textContent = e.msg;
line.appendChild(ts);
line.appendChild(msg);
frag.appendChild(line);
}
body.appendChild(frag);
if (logScrollLocked) body.scrollTop = body.scrollHeight;
applyLogFilters();
if (logSearchQuery) onLogSearch(logSearchQuery);
}
async function pollServerLogs() {
try {
const data = await api('/api/logs');
const entries = data?.entries || [];
const totalCount = data?.totalCount ?? entries.length;
if (totalCount <= logLastTotalCount) return;
const newCount = totalCount - logLastTotalCount;
// Slice new entries from the end of the ring (handles wrap-around correctly)
const newEntries = entries.slice(Math.max(0, entries.length - newCount));
renderLogEntries(newEntries);
logLastTotalCount = totalCount;
} catch {}
}
setInterval(pollServerLogs, 2000);
pollServerLogs();
// ─────────────────────────────────────────────────────────────────────────────
// Chat state
let chatHistory = [];
let chatSessions = [];
let activeChatSessionId = null;
let 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<agentId, { name, status, task, logs: [] }>
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) => `
<div class="queued-item">
<div class="queued-item-text">${escHtml(q)}</div>
<button class="queued-item-btn" onclick="removeQueuedPrompt(${i})">Remove</button>
</div>
`).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'].includes(mode)) mode = 'chat';
currentMode = mode;
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 aside = document.querySelector('aside');
const rightPanel = document.getElementById('right-panel');
if (aside) aside.style.display = '';
if (rightPanel) rightPanel.style.display = mode === 'chat' ? '' : 'none';
// sidebar tab 하이라이트 동기화
document.getElementById('tab-bgtasks')?.classList.toggle('active', mode === 'bgtasks');
if (mode === 'bgtasks') refreshBgTasks();
}
// --- 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 `<div class="hb-card ${statusClass}" data-job-id="${job.id}">
<div class="hb-card-drag">${sectionType === 'completed' ? '' : '\u2261'}</div>
<div class="hb-card-body">
<div class="hb-card-name">${escHtml(job.name)}</div>
<div class="hb-card-meta">
<span>${scheduleText}</span>
<span>Next: ${nextRunText}</span>
<span>Last: ${lastRunText}</span>
${resultSnippet && !isOk ? `<span style="color:var(--text);font-weight:600">${escHtml(resultSnippet)}</span>` : ''}
${isOk ? '<span style="color:var(--ok)">\u2713 OK</span>' : ''}
</div>
</div>
<div class="hb-card-actions">
${job.status !== 'running' ? `<button class="hb-card-btn" onclick="runTaskNow('${job.id}')" title="Run now">\u25b6</button>` : '<span style="font-size:11px;color:var(--brand)">Running...</span>'}
<button class="hb-card-btn" onclick="editTask('${job.id}')" title="Edit">\u270e</button>
<button class="hb-card-btn" onclick="toggleTaskPause('${job.id}')" title="${job.status === 'paused' ? 'Resume' : 'Pause'}">${job.status === 'paused' ? '\u25b6' : '\u23f8'}</button>
<button class="hb-card-btn danger" onclick="deleteTask('${job.id}')" title="Delete">\u2715</button>
${job.sessionId ? `<button class="hb-card-btn" onclick="viewTaskSession('${job.sessionId}')" title="View response">\u2192</button>` : ''}
</div>
</div>`;
}
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 = `<div class="hb-modal-overlay" onclick="if(event.target===this)closeTaskModal()">
<div class="hb-modal">
<h3>${job ? 'Edit Task' : 'Add Task'}</h3>
<label>Name</label>
<input id="tm-name" value="${job ? escHtml(job.name) : ''}" placeholder="e.g. Check inbox for urgent emails">
<label>Prompt (what the LLM should do)</label>
<textarea id="tm-prompt" placeholder="e.g. Search for any urgent emails or messages I should know about">${job ? escHtml(job.prompt) : ''}</textarea>
<label>Type</label>
<select id="tm-type">
<option value="recurring" ${!job || job.type==='recurring' ? 'selected' : ''}>Recurring (cron)</option>
<option value="one-shot" ${job?.type==='one-shot' ? 'selected' : ''}>One-shot (run once)</option>
</select>
<label>Schedule (cron expression)</label>
<input id="tm-schedule" value="${job?.schedule || '*/30 * * * *'}" placeholder="*/30 * * * * (every 30 min)">
<div style="font-size:10px;color:var(--muted);margin-top:2px">Examples: */30 * * * * (every 30m), 0 9 * * * (daily 9am), 0 9 * * 1 (Mon 9am)</div>
<div class="hb-modal-actions">
<button class="hb-btn" onclick="closeTaskModal()">Cancel</button>
<button class="hb-btn primary" onclick="saveTask()">Save</button>
</div>
</div>
</div>`;
}
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;
const toggle = (id, active) => document.getElementById(id)?.classList.toggle('active', active);
toggle('tab-jobs', tab === 'jobs');
toggle('tab-bgtasks', tab === 'bgtasks');
toggle('tab-skills', tab === 'skills');
toggle('tab-pptx', tab === 'pptx');
toggle('tab-schedule', tab === 'schedule');
document.getElementById('sidebar-jobs').style.display = (tab === 'jobs' || tab === 'bgtasks') ? 'flex' : 'none';
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none';
const schedEl = document.getElementById('sidebar-schedule');
if (schedEl) schedEl.style.display = tab === 'schedule' ? 'flex' : 'none';
// 메인 뷰 전환
if (tab === 'bgtasks') { setMode('bgtasks'); }
else if (currentMode === 'bgtasks') { setMode('chat'); }
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
if (tab === 'schedule') refreshSchedules();
}
// ---- Agent mode toggle ----
function updateAgentMode() {
useAgentMode = document.getElementById('agent-mode-toggle').checked;
document.getElementById('agent-mode-label').textContent = useAgentMode ? '에이전트' : '채팅';
document.getElementById('agent-mode-badge').classList.toggle('visible', useAgentMode);
document.getElementById('chat-input').placeholder = useAgentMode
? 'Ask agent to do something... (has access to tools)'
: 'Type a message... (Enter to send, Shift+Enter for newline)';
}
// ---- Chat ----
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 `
<div class="think-block">
<button class="think-toggle" id="${id}_btn" onclick="toggleThink('${id}')">
<span class="think-icon">...</span>
<span>Thought process</span>
<span style="margin-left:auto;opacity:0.5">${wordCount} words</span>
</button>
<div class="think-content" id="${id}_body">${escHtml(thinking)}</div>
</div>
`;
}
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 `
<div class="react-step">
${s.thought ? `<div class="react-step-thought">${escHtml(s.thought.slice(0, 120))}</div>` : ''}
<div class="react-step-action">${escHtml(s.action)}</div>
${resultText ? `<div class="react-step-result${resultText.startsWith('ERROR') ? ' error' : ''}">${escHtml(resultText.slice(0, 160))}${resultText.length > 160 ? '…' : ''}</div>` : ''}
${diag ? `
<div style="margin-top:6px;font-size:10px;color:var(--muted);line-height:1.5">
${query ? `<div><b>query</b>: ${escHtml(query)}</div>` : ''}
${selectedProvider ? `<div><b>provider hit</b>: ${escHtml(selectedProvider)}</div>` : ''}
${providers ? `<div><b>providers</b>: ${escHtml(providers)}</div>` : ''}
</div>
` : ''}
${String(s.action || '') === 'web_search' && !diag ? `
<div style="margin-top:6px;font-size:10px;color:var(--muted);line-height:1.5">
${selectedProvider ? `<div><b>provider hit</b>: ${escHtml(selectedProvider)}</div>` : ''}
</div>
` : ''}
${urls.length ? `
<div style="margin-top:6px;font-size:10px;line-height:1.6">
<div style="color:var(--muted);margin-bottom:2px"><b>sources</b>:</div>
${urls.slice(0, 3).map((u, i) => `<div>${i + 1}. <a href="${escHtml(u)}" target="_blank" rel="noopener noreferrer">${escHtml(u)}</a></div>`).join('')}
</div>
` : ''}
</div>
`;
}).join('');
return `
<div class="react-steps-toggle" onclick="toggleSteps('${id}')">
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
</div>
<div class="react-steps" id="${id}" style="display:none">${items}</div>
`;
}
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(`
<div class="pptx-file-row" style="margin:6px 0">
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
<span class="download-icon">&#11015;</span> ${escHtml(filename)}</a>
<button class="pptx-preview-btn" onclick="togglePptxPreview('${escHtml(rawUrl)}', '${previewId}', this)" title="Preview slides">&#9664; Preview</button>
<div id="${previewId}" class="pptx-preview-strip" style="display:none"></div>
</div>`);
} else {
buttons.push(`
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
<span class="download-icon">&#11015;</span> ${escHtml(filename)}</a>`);
}
}
return buttons.length ? `<div style="margin-top:8px">${buttons.join(' ')}</div>` : '';
}
function renderAssistantContent(content) {
const text = String(content || '');
const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text);
if (!hasStaged) return `<div class="msg-content">${renderMarkdown(text)}</div>`;
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 `
<div class="msg-stage">
<div class="msg-stage-title">${escHtml(title)}</div>
<div class="msg-content">${renderMarkdown(body)}</div>
</div>
`;
}).join('');
return `<div class="msg-staged">${html}</div>`;
}
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 `
<div class="artifact-card">
<div class="artifact-head">
<div class="artifact-title">${escHtml(title)}</div>
<div class="artifact-status ${escHtml(status)}">${escHtml(status)}</div>
</div>
${pathVal ? `<div class="artifact-row"><b>Path:</b> <code>${escHtml(pathVal)}</code></div>` : ''}
${fromPath ? `<div class="artifact-row"><b>From:</b> <code>${escHtml(fromPath)}</code></div>` : ''}
${toPath ? `<div class="artifact-row"><b>To:</b> <code>${escHtml(toPath)}</code></div>` : ''}
${summary ? `<div class="artifact-row">${escHtml(summary)}</div>` : ''}
${files.length ? `<div class="artifact-row"><b>Files:</b> ${escHtml(files.join(', '))}${Array.isArray(a?.files) && a.files.length > files.length ? ' ...' : ''}</div>` : ''}
${dirs.length ? `<div class="artifact-row"><b>Directories:</b> ${escHtml(dirs.join(', '))}${Array.isArray(a?.directories) && a.directories.length > dirs.length ? ' ...' : ''}</div>` : ''}
${preview ? `<div class="artifact-preview">${escHtml(preview)}</div>` : ''}
${locationTarget ? `
<div class="artifact-actions">
<button class="artifact-btn" onclick="openInFileLocation('${escHtml(locationTarget).replace(/'/g, '&#39;')}')">Open In File Location</button>
</div>
` : ''}
</div>
`;
}).join('');
return `<div class="artifact-list">${cards}</div>`;
}
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 = `
<div class="chat-welcome" id="chat-welcome">
<div class="chat-welcome-icon"><img src="cherry_logo.png" alt="🍒"></div>
<h2>CherryClaw</h2>
<p>Chat directly with your local model. No API keys, no cloud — just you and the model.</p>
<div class="hint">Enable Agent mode when you want planning + tool execution in this chat.</div>
</div>`;
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 `<div class="msg widget${pinClass}${selectedClass}${confirmedClass}"${clickHandler}><div class="msg-content">${msg.content}</div></div>`;
}
return `
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
<div class="msg-body">
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}${msg.role === 'ai' ? ` <button class="tts-btn" onclick="playTTSMsg(this,${idx})" title="음성으로 듣기">🔊</button>` : ''}</div>
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}</div>`}
${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) : ''}
</div>
</div>`;
}).join('');
validateDownloadLinks(container);
if (isThinking && streamingSessionId === activeChatSessionId) {
const progressHtml = currentProgressLines.length
? `<div style="margin:6px 0 8px 0;font-size:11px;line-height:1.6;color:var(--muted)">
${currentProgressLines.map((l) => `<div>• ${escHtml(l)}</div>`).join('')}
</div>`
: '';
container.innerHTML += `
<div class="msg ai" id="thinking-msg">
<div class="msg-avatar"><img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px"></div>
<div class="msg-body">
<div class="msg-role">CherryClaw${useAgentMode ? ' (Agent)' : ''}</div>
${currentPreflightStatus ? `<div class="msg-content" style="margin-bottom:6px;color:#26487e">${escHtml(currentPreflightStatus)}</div>` : ''}
${progressHtml}
<div class="thinking" style="align-items:center">
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<span id="thinking-elapsed" style="margin-left:8px;font-size:11px;color:var(--muted)"></span>
</div>
<div style="margin-top:8px">
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="toggleCurrentProcess()">Process</button>
<div id="current-turn-process" style="display:none;margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);padding:8px;max-height:220px;overflow:auto;font-size:11px;line-height:1.6"></div>
</div>
</div>
</div>`;
}
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 `
<div style="margin-top:8px">
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="togglePL('${id}')">Process</button>
<div id="${id}" style="display:none;margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);padding:8px;max-height:220px;overflow:auto;font-size:11px;line-height:1.6">
${formatProcessLines(entries)}
</div>
</div>
`;
}
function formatProcessLines(entries) {
if (!entries || entries.length === 0) return '<div style="color:var(--muted)">No process details.</div>';
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 `<div style="margin-bottom:4px"><span style="color:var(--muted)">[${escHtml(String(e.ts || ''))}]</span> <span style="color:${typeColor};font-weight:600">${typeLabel}</span> ${content}</div>`;
}).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 = '<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>';
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 = '<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>';
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 `
<div style="margin-bottom:4px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:var(--muted);cursor:pointer;text-decoration:underline;font-size:10px" onclick="togglePL('${id}')">${escHtml(preview)}…</span>
</div>
<div id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;white-space:pre-wrap;color:var(--muted);font-size:10px;max-height:200px;overflow-y:auto;font-style:italic">${escHtml(e.content)}</div>
`;
}
// 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 `
<div style="margin-bottom:4px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:${color};cursor:pointer;font-size:10px" onclick="togglePL('${id}')">${escHtml(preview)}… <span style="opacity:0.5">[expand]</span></span>
</div>
<div id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;white-space:pre-wrap;color:var(--text);font-size:10px;max-height:300px;overflow-y:auto">${escHtml(e.content)}</div>
`;
}
// Memory suggestion: show approve/reject buttons
if (e.type === 'memory') {
const id = 'pl_mem_' + i;
const suggest = e.extra || {};
return `
<div style="margin-bottom:6px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:var(--text);">${escHtml(String(e.content || ''))}</span>
<div style="margin-top:6px">
<button class="btn btn-sm" onclick="confirmMemory(${i})">Approve</button>
<button class="btn btn-sm" style="margin-left:6px" onclick="rejectMemory(${i})">Reject</button>
</div>
</div>
`;
}
// 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 `
<div style="margin-bottom:3px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color}">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:var(--text);margin-left:8px">${escHtml(String(e.content || ''))}</span>
<span style="color:var(--muted);cursor:pointer;margin-left:8px;font-size:10px" onclick="togglePL('${id}')">[details]</span>
</div>
<pre id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;color:var(--muted);font-size:11px;max-height:300px;overflow:auto">${pretty}</pre>
`;
}
return `<div style="margin-bottom:3px"><span style="color:var(--muted);font-size:10px">${e.ts}</span> <span style="color:${color}">${icon} <span style="color:${labelColor}">${label}</span></span> <span style="color:var(--text)">${escHtml(String(e.content || ''))}</span></div>`;
}).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, '&quot;');
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
const imgOnerr = `this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; ${imgErrAlt}를 불러올 수 없습니다.</span>')`;
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" onerror="${imgOnerr}" />`;
// 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 = '<div style="padding:8px 12px;font-size:11px;color:var(--muted)">작업 실행 대기 중...</div>';
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 => `<div class="agent-log-entry"><span class="agent-log-entry-time">${escHtml(log.ts)}</span><span class="agent-log-entry-type">[${escHtml(log.type.toUpperCase())}]</span><span class="agent-log-entry-content">${escHtml(log.content)}</span></div>`).join('')
: '<div style="color:var(--muted);opacity:0.6">No logs yet</div>';
return `
<div class="agent-item ${agent.expanded ? 'expanded' : ''}" onclick="toggleAgentPanel('${agent.id}')">
<div class="agent-item-header">
<span style="color:var(--muted);font-size:10px;min-width:12px">${arrow}</span>
<div class="agent-item-name">${escHtml(agent.isSubagent ? '└─ ' + agent.name : agent.name)}</div>
<div class="agent-item-status ${statusClass}">${agent.status.toUpperCase()}</div>
</div>
<div class="agent-item-task">${escHtml(agent.task.substring(0, 70))}${agent.task.length > 70 ? '...' : ''}</div>
<div class="agent-item-logs">${logsHtml}</div>
</div>
`;
}).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 = `<span style="color:var(--brand)">Working: ${escHtml((msg.data?.action || '').slice(0,40))}...</span>`;
}
}
// -- 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', 'voice', 'session', 'context-viewer'];
tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`);
if (btn) {
btn.style.background = (t === tab) ? '#eaf2ff' : '#fff';
btn.style.borderColor = (t === tab) ? '#bdd3f6' : 'var(--line)';
btn.style.color = (t === tab) ? '#0d4faf' : 'var(--muted)';
}
if (panel) {
if (t === tab) {
panel.style.display = 'block';
if (t === 'channels') loadChannelsStatus();
if (t === 'models') loadModelSettings();
if (t === 'agents') loadAgentsTab();
if (t === 'integrations') loadIntegrationsTab();
if (t === 'credentials') loadCredentialsTab();
if (t === 'users') loadUsersTab();
if (t === 'voice') loadVoiceSettings();
if (t === 'session') loadSessionSettings();
if (t === 'context-viewer') loadContextViewerTab();
} 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 = '<span style="color:var(--warn)">⚠ No credentials stored yet.</span>';
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 `<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px">
<span style="color:var(--ok);font-size:13px">✓</span>
<span style="font-size:12px;color:var(--text)">${label}</span>
<span style="font-size:10px;color:var(--muted);font-family:monospace">${k}</span>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<span style="color:var(--err);font-size:12px">Could not load vault status: ${e.message}</span>`;
}
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const color = l.includes('SET') ? 'var(--ok)' : l.includes('GET') ? 'var(--brand)' : l.includes('DEL') ? 'var(--err)' : 'var(--muted)';
return `<div style="color:${color};white-space:nowrap">${safe}</div>`;
}).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 => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).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 => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).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. <a href="' + data.authUrl + '" target="_blank">Click here to authorize</a>, then paste the redirect URL below.<br><input id="codex-manual-url" type="text" placeholder="Paste redirect URL here…" style="width:100%;margin-top:6px;padding:6px;border:1px solid var(--line);border-radius:8px;font-size:11px"><button onclick="submitManualCodexUrl()" style="margin-top:4px;font-size:11px;padding:4px 8px;border-radius:6px;border:1px solid var(--line);cursor:pointer">Submit</button>';
} 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 = '<div style="font-size:11px;color:var(--muted)">활성 턴 실행 없음.</div>';
} 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'
? `<div class="task-meta">${escHtml(JSON.stringify(s.outputs).slice(0, 160))}</div>`
: '';
return `
<div class="task-row">
<div class="task-top">
<div class="task-title">${idx + 1}. ${stepTitle}</div>
<span class="status-chip ${stepStatus}">${stepStatus.replace('_', ' ')}</span>
</div>
${outputs}
</div>
`;
}).join('');
const verifyHtml = verification
? `<div class="task-meta">verify: <strong>${escHtml(String(verification.status || 'pass'))}</strong>${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}` : ''}</div>`
: '<div class="task-meta">verify: (not run)</div>';
const traceHtml = traceEvents.length
? `
<div class="task-row">
<div class="task-top">
<div class="task-title">Decision Trace</div>
<span class="status-chip done">${traceEvents.length}</span>
</div>
${traceEvents.slice(-10).map((ev) => {
const stage = escHtml(String(ev?.stage || 'trace'));
const msg = escHtml(String(ev?.message || '').slice(0, 180));
return `<div class="task-meta">[${stage}] ${msg}</div>`;
}).join('')}
</div>
`
: '';
execPanel.innerHTML = `
<div class="task-row">
<div class="task-top">
<div class="task-title">Current Turn</div>
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
</div>
<div class="task-meta">${objective}</div>
<div class="task-meta">mode: <strong>${mode}</strong> • kind: <strong>${turnKind}</strong></div>
${verifyHtml}
</div>
${stepRows || '<div style="font-size:11px;color:var(--muted)">No execution steps.</div>'}
${traceHtml}
`;
}
}
const taskList = document.getElementById('r-task-list');
if (taskList) {
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
if (!tasks.length) {
taskList.innerHTML = '<div style="font-size:11px;color:var(--muted)">작업 없음.</div>';
} 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 `
<div class="task-row">
<div class="task-top">
<div class="task-title">${title}</div>
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
</div>
<div class="task-meta">tool: ${tool}</div>
</div>
`;
}).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 `<div style="margin-bottom:6px"><span style="color:var(--muted)">#${idx + 1} [${status}]</span> ${objective || '(objective)'}<div style="font-size:10px;color:var(--muted)">tools: ${tools}${traceCount ? ` • trace: ${traceCount}` : ''}${repairs ? ` • repairs: ${repairs}` : ''}</div></div>`;
}).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 `<div style="margin-bottom:6px"><span style="color:var(--muted)">[${kind}/${status}]</span> ${text}</div>`;
}).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 = '<div style="font-size:11px;color:var(--muted)">활성 도구 단계 없음.</div>';
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 ? `<div class="task-meta">${escHtml(String(s.meta).slice(0, 140))}</div>` : '';
return `
<div class="task-row">
<div class="task-top">
<div class="task-title">${stepNum ? `Step ${stepNum}: ` : ''}${action}</div>
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
</div>
${meta}
</div>
`;
}).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 = '<div style="font-size:12px;color:var(--muted)">인증이 비활성화되어 있거나 권한이 없습니다.</div>'; return; }
if (data.users.length === 0) {
container.innerHTML = '<div style="font-size:12px;color:var(--muted)">등록된 사용자가 없습니다.</div>';
return;
}
container.innerHTML = data.users.map(u => `
<div style="border-bottom:1px solid var(--line);padding:8px 0">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserTelegram('${u.username}')">✈ 텔레그램</button>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserEmail('${u.username}')">✉ 이메일</button>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
</div>
<div id="user-telegram-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
<div id="user-email-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div style="font-size:12px;color:var(--err)">사용자 목록을 불러올 수 없습니다.</div>';
}
}
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 = '<div style="font-size:12px;color:var(--muted)">불러오는 중...</div>';
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 = `
<div style="font-size:12px;font-weight:600;margin-bottom:8px">텔레그램 전용 봇 — ${username}</div>
<div style="font-size:11px;color:${statusColor};margin-bottom:8px">● ${statusText}</div>
<div style="display:flex;gap:6px;align-items:center">
<input id="tg-token-${username}" type="password" placeholder="${data.hasToken ? '●●●●●●●● (저장됨 — 변경 시 입력)' : '봇 토큰 입력 (BotFather에서 발급)'}"
style="flex:1;padding:5px 8px;border:1px solid var(--line);border-radius:6px;font-size:12px;background:var(--panel-1);color:var(--text)">
<button onclick="saveUserTelegramBot('${username}')" style="padding:5px 12px;font-size:11px;background:var(--brand);color:#fff;border:none;border-radius:6px;cursor:pointer">저장</button>
${data.hasToken ? `<button onclick="deleteUserTelegramBot('${username}')" style="padding:5px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer">삭제</button>` : ''}
</div>
<div id="tg-bot-msg-${username}" style="font-size:11px;margin-top:6px"></div>
`;
} catch (e) {
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">${e.message}</div>`;
}
}
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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
async function loadUserEmailAccounts(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중…</div>';
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
? '<div style="font-size:12px;color:var(--muted);margin-bottom:8px">등록된 이메일 계정이 없습니다.</div>'
: accounts.map(a => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:12px">
<strong style="min-width:90px">${escapeHtml(a.id)}</strong>
${def === a.id ? '<span style="font-size:10px;background:var(--brand);color:#fff;padding:1px 6px;border-radius:6px">기본</span>' : ''}
<span style="color:var(--muted);flex:1;font-family:'IBM Plex Mono',monospace;font-size:11px">${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port}</span>
${a.password_set ? '' : '<span style="font-size:10px;color:var(--err)">비번 없음</span>'}
${def !== a.id ? `<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="setDefaultUserEmailAccount('${username}','${escapeHtml(a.id)}')">기본 설정</button>` : ''}
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="testUserEmailAccount('${username}','${escapeHtml(a.id)}',this)" ${a.password_set ? '' : 'disabled'}>테스트</button>
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer" onclick="deleteUserEmailAccount('${username}','${escapeHtml(a.id)}')">삭제</button>
</div>`).join('');
panel.innerHTML = `
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">이메일 계정 (${accounts.length})</div>
${list}
<details style="margin-top:8px"><summary style="font-size:12px;cursor:pointer;color:var(--muted)">+ 계정 추가/수정</summary>
<div id="${formId}" style="margin-top:8px;padding:10px;background:#fff;border:1px solid var(--line);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px">
<label style="grid-column:1/3"><span style="color:var(--muted);font-size:11px">계정 ID (예: applecherry)</span>
<input data-f="id" type="text" placeholder="applecherry" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 호스트</span>
<input data-f="imap_host" type="text" placeholder="imap.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 포트</span>
<input data-f="imap_port" type="number" value="993" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 호스트</span>
<input data-f="smtp_host" type="text" placeholder="smtp.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 포트</span>
<input data-f="smtp_port" type="number" value="587" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">사용자명 (이메일)</span>
<input data-f="username" type="text" placeholder="user@example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">비밀번호 (vault에 암호화 저장)</span>
<input data-f="password" type="password" autocomplete="new-password" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="imap_tls" type="checkbox" checked /> IMAP TLS</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="smtp_secure" type="checkbox" /> SMTP secure (465)</label>
<button class="btn btn-primary" style="grid-column:1/3;padding:7px;margin-top:6px;font-size:12px" onclick="submitUserEmailAccount('${username}')">저장</button>
<div id="${formId}-msg" style="grid-column:1/3;font-size:11px;color:var(--err)"></div>
</div>
</details>`;
} catch (e) {
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">불러오기 실패: ${escapeHtml(e.message || e)}</div>`;
}
}
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 = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>';
if (warnEl) warnEl.style.display = 'none';
try {
const data = await api('/api/admin/context-viewer');
if (!data || !data.sessions) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--err)">권한이 없거나 데이터를 불러올 수 없습니다.</div>';
return;
}
const sessions = data.sessions;
if (sessions.length === 0) {
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">세션이 없습니다.</div>';
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 `<strong style="color:${color}">${s.username}</strong> (${s.id.slice(0,8)}…) ${s.usagePct}%`;
}).join(' &nbsp;|&nbsp; ');
}
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
? '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px"></span>'
: '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--line-strong);margin-right:4px"></span>';
return `
<div id="cv-card-${escHtml(s.id)}" style="border:1px solid var(--line);border-radius:12px;padding:12px;background:var(--panel)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div style="display:flex;align-items:center;gap:8px">
${onlineDot}
<strong style="font-size:13px">${escHtml(s.username)}</strong>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted)">${s.id.slice(0,12)}…</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;color:var(--muted)">${lastActive}</span>
${pct >= 70 ? `<span style="font-size:11px;font-weight:700;color:${barColor};padding:2px 7px;border-radius:6px;background:${pct>=90?'#fee2e2':'#fff7ed'}">${pct}%</span>` : ''}
<button onclick="cvDeleteSession('${escHtml(s.username)}','${escHtml(s.id)}')" title="세션 삭제" style="background:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px;color:var(--muted);cursor:pointer" onmouseover="this.style.borderColor='var(--err)';this.style.color='var(--err)'" onmouseout="this.style.borderColor='var(--line)';this.style.color='var(--muted)'">✕</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
<div style="flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden">
<div style="height:100%;width:${Math.min(pct,100)}%;background:${barColor};border-radius:3px;transition:width .3s"></div>
</div>
<span style="font-size:11px;color:var(--muted);white-space:nowrap">${s.contextTokenEstimate.toLocaleString()} / ${s.numCtx.toLocaleString()} tok</span>
</div>
<div style="display:flex;gap:8px;font-size:11px;color:var(--muted);margin-bottom:6px">
<span>메시지 ${s.historyCount}개</span>
</div>
${s.lastUserMessage ? `<div style="font-size:11px;padding:6px 8px;background:var(--panel-2);border-radius:8px;margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%"><span style="color:var(--brand);font-weight:600">사용자: </span>${escHtml(s.lastUserMessage)}</div>` : ''}
${s.lastAssistantMessage ? `<div style="font-size:11px;padding:6px 8px;background:var(--panel-2);border-radius:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%"><span style="color:var(--ok);font-weight:600">AI: </span>${escHtml(s.lastAssistantMessage)}</div>` : ''}
</div>`;
}).join('');
} catch (e) {
listEl.innerHTML = `<div style="font-size:12px;color:var(--err)">오류: ${e.message || '알 수 없는 오류'}</div>`;
}
}
async function cvDeleteSession(username, sessionId) {
try {
const res = await fetch(`/api/admin/sessions/${encodeURIComponent(username)}/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
const card = document.getElementById('cv-card-' + sessionId);
if (card) card.remove();
} catch (e) {
alert('삭제 실패: ' + (e.message || e));
}
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// --- PPT Template & Skin Tab ---------------------------------------------------
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 = '<div style="color:var(--muted)">No agents found.</div>';
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 ? '<span style="font-size:10px;padding:2px 6px;border-radius:999px;background:#eaf2ff;color:#0d4faf;border:1px solid #bdd3f6">default</span>' : '';
return `
<button onclick="selectAgent('${escHtml(a.id)}')" style="text-align:left;border:1px solid ${selected ? '#bdd3f6' : 'var(--line)'};border-radius:10px;padding:8px;background:${selected ? '#f5f9ff' : '#fff'};cursor:pointer">
<div style="display:flex;align-items:center;gap:6px">
<div style="font-size:15px">${escHtml(a.emoji || '🤖')}</div>
<div style="font-weight:700;color:var(--text)">${escHtml(a.name || a.id)}</div>
${badge}
</div>
<div style="margin-top:4px;font-family:'IBM Plex Mono',monospace;color:var(--muted)">${escHtml(a.id)}</div>
<div style="margin-top:4px;font-size:11px;color:var(--muted)">last run: ${escHtml(lastRun)}</div>
<div style="font-size:11px;color:var(--muted)">last heartbeat: ${escHtml(heartbeat)}</div>
</button>
`;
}).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 = '<div style="color:var(--muted)">No runs yet.</div>';
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 `<div style="border:1px solid var(--line);border-radius:8px;padding:6px 8px">
<div style="display:flex;justify-content:space-between;gap:8px">
<span style="font-weight:700;color:${color}">${r.success ? 'success' : 'failed'}</span>
<span style="font-size:11px;color:var(--muted)">${escHtml(when)}</span>
</div>
<div style="font-size:11px;color:var(--muted)">${escHtml(label)}</div>
<div style="font-size:11px;color:var(--text);margin-top:2px">${escHtml(preview)}</div>
</div>`;
}).join('');
} catch (err) {
el.innerHTML = `<div style="color:#9c1a1a">Failed to load history: ${escHtml(err.message)}</div>`;
}
}
// --- 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 === 'kakao') {
let userMap = {};
try { userMap = JSON.parse(document.getElementById('settings-kk-usermap')?.value || '{}'); } catch {}
return {
appKey: (document.getElementById('settings-kk-appkey')?.value || '').trim(),
channelEnabled: !!document.getElementById('settings-kk-channel-enabled')?.checked,
userMap,
};
}
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',
kakao: 'channel-form-kakao',
discord: 'channel-form-discord',
whatsapp: 'channel-form-whatsapp',
email: 'channel-form-email',
};
const guideMap = {
telegram: 'channel-guide-telegram',
kakao: 'channel-guide-kakao',
discord: 'channel-guide-discord',
whatsapp: 'channel-guide-whatsapp',
email: 'channel-guide-email',
};
const statusMap = {
telegram: 'tg-status-bar',
kakao: 'kk-status-bar',
discord: 'dc-status-bar',
whatsapp: 'wa-status-bar',
email: 'em-status-bar',
};
['telegram', 'kakao', '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)
// Kakao: hide "테스트" (연결 확인 없음), 테스트 전송만 노출
const testBtn = document.getElementById('channel-test-btn');
const sendTestBtn = document.getElementById('channel-send-test-btn');
if (testBtn) testBtn.style.display = (ch === 'email' || ch === 'kakao') ? 'none' : '';
if (sendTestBtn) sendTestBtn.style.display = (ch === 'email') ? 'none' : '';
if (ch === 'email') loadEmailAccounts();
if (ch === 'kakao') loadKakaoStatus();
}
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 loadKakaoStatus() {
try {
const data = await api('/api/kakao/status');
const n = data?.notify || {};
const c = data?.channel || {};
if (n.hasAppKey && n.hasRefreshToken) setChannelStatus('kk', 'ok', '알림 연동됨' + (c.enabled ? ' + 챗봇 활성' : ''));
else if (n.hasAppKey) setChannelStatus('kk', 'warn', 'API 키 있음 — OAuth 로그인 필요');
else setChannelStatus('kk', 'off', '미설정');
if (n.hasAppKey) document.getElementById('settings-kk-appkey').placeholder = '•••••••• (저장됨)';
document.getElementById('settings-kk-channel-enabled').checked = !!c.enabled;
} catch {}
}
async function kakaoOAuthStart() {
const appKey = (document.getElementById('settings-kk-appkey')?.value || '').trim();
if (appKey) {
// 먼저 appKey 저장
await saveChannelSettings('kakao');
}
const status = document.getElementById('kk-oauth-status');
if (status) status.textContent = '새 창에서 로그인 중...';
const w = window.open('/api/kakao/auth/start', '_blank', 'width=600,height=700');
if (!w) { alert('팝업이 차단됐습니다. 팝업을 허용해 주세요.'); return; }
const timer = setInterval(async () => {
if (w.closed) {
clearInterval(timer);
await loadKakaoStatus();
if (status) status.textContent = '';
}
}, 1000);
}
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 = '<div style="font-size:12px;color:var(--muted)">사용자 정보를 불러올 수 없습니다.</div>';
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 = '<div style="font-size:12px;color:var(--muted)">이메일 계정을 불러올 수 없습니다.</div>';
}
}
function renderEmailAccountsList(accounts) {
const container = document.getElementById('email-accounts-list');
if (!accounts.length) {
container.innerHTML = '<div style="font-size:12px;color:var(--muted);margin-bottom:6px">등록된 이메일 계정이 없습니다.</div>';
return;
}
container.innerHTML = accounts.map(a => {
const esc = s => String(s||'').replace(/</g,'&lt;');
return `<div style="border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:6px;background:var(--panel-2)">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<span style="font-weight:600;font-size:13px">${esc(a.id)}</span>
<span style="font-size:11px;color:var(--muted);margin-left:6px">${esc(a.username)}</span>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-sm" onclick="testEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid var(--line);color:var(--text);padding:3px 8px;font-size:11px">테스트</button>
<button class="btn btn-sm" onclick="editEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid var(--line);color:var(--text);padding:3px 8px;font-size:11px">수정</button>
<button class="btn btn-sm" onclick="deleteEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid #e55;color:#e55;padding:3px 8px;font-size:11px">삭제</button>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:2px">IMAP: ${esc(a.imap_host)}:${a.imap_port} | SMTP: ${esc(a.smtp_host)}:${a.smtp_port}</div>
</div>`;
}).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 = '<span class="spinner"></span> 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 = '<div class="empty-state">No chats yet.<br>Start a new conversation.</div>';
return;
}
const sorted = [...chatSessions].sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt));
el.innerHTML = sorted.map(s => `
<div class="job-item ${s.id === activeChatSessionId ? 'active' : ''}${s.unread ? ' unread' : ''}" onclick="openSession('${s.id}')">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<div class="job-item-title" style="flex:1">${s.unread ? '🔵 ' : ''}${escHtml(s.title || 'New chat')}</div>
${sessionsEditMode ? `<button class="sidebar-action" style="padding:3px 8px;font-size:10px" onclick="deleteChatSession('${s.id}', event)">삭제</button>` : ''}
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
${s.automated ? '<span class="session-auto-badge">Auto</span>' : ''}
<span class="badge badge-queued">${(s.history || []).length} msgs</span>
<span style="color:var(--muted);font-size:10px">${timeAgo(s.updatedAt || s.createdAt)}</span>
</div>
</div>
`).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 = `
<div class="job-detail-title">${escHtml(job.title)}</div>
<div class="job-detail-meta">
<span class="badge badge-${job.status}">${isRunning ? '<span class="spinner"></span>' : ''}${job.status}</span>
<span>ID: ${job.id.slice(0, 12)}...</span>
<span>${timeAgo(job.created_at)}</span>
</div>
${state ? `
<div class="section-title">Mission</div>
<div style="color:var(--muted);font-size:12px;line-height:1.7;background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:16px">
${escHtml(state.mission)}
</div>` : ''}
${tasks && tasks.length > 0 ? `
<div class="section-title">Tasks (${tasks.length})</div>
${tasks.map(t => `
<div class="task-card">
<div class="task-card-header">
<span class="task-card-title">${escHtml(t.title)}</span>
<span class="badge badge-${t.status}">${t.status}</span>
</div>
<div class="task-card-desc">${escHtml(t.description || '')}</div>
${t.acceptance_criteria && t.acceptance_criteria.length > 0 ? `
<div class="task-criteria">
${t.acceptance_criteria.map(c => `<div class="task-criteria-item">${escHtml(c)}</div>`).join('')}
</div>` : ''}
</div>
`).join('')}` : `
<div class="section-title">작업</div>
<div style="color:var(--muted);font-size:12px;padding:20px 0">
${isRunning ? '<span class="spinner pulsing"></span> Planning tasks...' : 'No tasks'}
</div>`}
${artifacts && artifacts.length > 0 ? `
<div class="section-title" style="margin-top:16px">Artifacts (${artifacts.length})</div>
${artifacts.map(a => `
<div class="task-card">
<div class="task-card-header">
<span class="task-card-title">${escHtml(a.path || a.type)}</span>
<span class="badge badge-completed">${a.type}</span>
</div>
${a.content ? `<div class="task-card-desc" style="margin-top:6px">${escHtml(a.content.slice(0, 200))}${a.content.length > 200 ? '...' : ''}</div>` : ''}
</div>
`).join('')}` : ''}
<div id="log-panel">${logLines.map(l => `<div class="log-line ${l.type}">${l.text}</div>`).join('')}</div>
`;
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 = '<div style="color:var(--muted);font-size:11px">대기 중인 승인 없음</div>';
return;
}
el.innerHTML = approvals.map(a => `
<div class="approval-card">
<div class="approval-action">${escHtml(a.action)}</div>
<div class="approval-reason">${escHtml(a.reason || 'Approval required')}</div>
<div class="approval-buttons">
<button class="btn btn-sm btn-green" onclick="resolveApproval('${a.id}','approved')">? Approve</button>
<button class="btn btn-sm btn-red" onclick="resolveApproval('${a.id}','rejected')">? Reject</button>
</div>
</div>
`).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);
}
function fmtNetSpeed(bps) {
if (bps >= 1024 * 1024 * 1024) return (bps / (1024 ** 3)).toFixed(1) + ' GB/s';
if (bps >= 1024 * 1024) return (bps / (1024 ** 2)).toFixed(1) + ' MB/s';
if (bps >= 1024) return (bps / 1024).toFixed(0) + ' KB/s';
return bps.toFixed(0) + ' B/s';
}
// 네트워크 바: 최근 최댓값 기준으로 스케일
let _netPeak = 1024 * 1024; // 초기 1 MB/s 기준
function netBarPct(bps) {
if (bps > _netPeak) _netPeak = bps;
return Math.min(100, (bps / _netPeak) * 100);
}
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);
// 사이드바 리소스 바
setText('sb-sys-cpu', fmtPercent(cpu));
setText('sb-sys-ram', fmtPercent(ram));
setText('sb-sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
setMeter('sb-sys-cpu-meter', cpu);
setMeter('sb-sys-ram-meter', ram);
setMeter('sb-sys-vram-meter', gpuAvailable ? vram : 0);
const rxBps = stats?.network?.rx_bps || 0;
const txBps = stats?.network?.tx_bps || 0;
setText('sb-sys-rx', fmtNetSpeed(rxBps));
setText('sb-sys-tx', fmtNetSpeed(txBps));
setMeter('sb-sys-rx-meter', netBarPct(rxBps));
setMeter('sb-sys-tx-meter', netBarPct(txBps));
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 =>
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok)"></span>${escHtml(String(u))}</span>`
).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);
setText('sb-sys-cpu', '--%');
setText('sb-sys-ram', '--%');
setText('sb-sys-vram', 'N/A');
setMeter('sb-sys-cpu-meter', 0);
setMeter('sb-sys-ram-meter', 0);
setMeter('sb-sys-vram-meter', 0);
setText('sb-sys-rx', '0 B/s');
setText('sb-sys-tx', '0 B/s');
setMeter('sb-sys-rx-meter', 0);
setMeter('sb-sys-tx-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 = '<option value="">— select provider first —</option>';
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 = '<option value="">— 감지된 모델 없음 —</option>';
modelSelect.value = '';
return;
}
modelSelect.innerHTML = models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).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 = `<strong>Advisor (${modeLabel})</strong><br><span style="color:var(--muted)">Trigger: ${data.reason || data.trigger}${routeText}${countText}</span>`;
if (data.advice?.next_actions?.length) {
el.innerHTML += '<br><span style="color:#e2e8f0">? ' + data.advice.next_actions[0] + '</span>';
}
if (data.preflight?.quick_plan?.length) {
el.innerHTML += '<br><span style="color:#e2e8f0">? ' + data.preflight.quick_plan[0] + '</span>';
}
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 => `<div class="log-line ${l.type}">${l.text}</div>`).join('');
lp.scrollTop = lp.scrollHeight;
}
}
// ---- Helpers ----
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 '<div class="pptx-file-row">'
+ '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>'
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="미리보기">&#9664; 미리보기</button>'
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
+ '</div>';
}
if (isPdf) {
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
return '<div class="pdf-file-row" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<a href="' + url + '" target="_blank" rel="noopener" class="download-btn" title="새 탭에서 열기">'
+ '<span style="font-size:14px">📄</span> ' + text + '</a>'
+ '<button class="pptx-preview-btn" onclick="togglePdfEmbed(\'' + escHtml(url) + '\',\'' + pdfId + '\',this)">&#9664; 미리보기</button>'
+ (hideDownload ? '' : '<a href="' + url + '" onclick="downloadFile(event,this)" class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>')
+ '</div>'
+ '<div id="' + pdfId + '" style="display:none">'
+ '<iframe style="width:100%;height:600px;border:1px solid var(--line);border-radius:8px" allowfullscreen></iframe>'
+ '</div>'
+ '</div>';
}
if (isXlsx) {
var xlsxSafeUrl = escHtml(url);
return '<div class="pdf-file-row xlsx-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<span style="font-size:14px">📊</span>'
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" data-xlsx-save-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">✏️ 편집</button>'
+ '<button class="pptx-preview-btn" onclick="openXlsxNewWindow(\'' + xlsxSafeUrl + '\',\'' + escHtml(text) + '\')" style="background:linear-gradient(135deg,#6366f1,#4f46e5)" title="새 창에서 열기">↗ 새 창</button>'
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">&#11015; 다운로드</a>'
+ '</div>'
+ ''
+ '</div>';
}
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
if (isTxt) {
var txtLabel = escHtml(text.replace(/^📝\s*/, ''));
return '<div class="pdf-file-row txt-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<span style="font-size:14px">📝</span>'
+ '<span style="font-weight:600;color:var(--text)">' + txtLabel + '</span>'
+ '<button class="pptx-preview-btn" data-txt-url="' + escHtml(url) + '" onclick="toggleTxtEmbedBtn(this)">&#9664; 미리보기</button>'
+ '<a href="' + escHtml(url) + '" download="' + txtLabel + '" class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>'
+ '</div>'
+ '<div class="txt-preview-body" style="display:none;max-height:420px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;word-break:break-word;line-height:1.7;color:var(--text);font-family:inherit"></div>'
+ '</div>';
}
if (isDownload) {
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>';
}
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
}
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 = '&#9650; 닫기';
} else {
container.style.display = 'none';
btn.innerHTML = '&#9664; 미리보기';
}
}
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 = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#666;font-size:13px">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>';
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 = '<div style="padding:20px;color:red">Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.</div>'; 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 = '<div style="padding:20px;color:red">Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '</div>';
});
});
})
.catch(function(e) {
container.innerHTML = '<div style="padding:20px;color:red">불러오기 실패: ' + escHtml(e.message) + '</div>';
});
}
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 = '&#9723;';
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 = '&#128190; 저장';
if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '&#128190; 저장'; }, 2000); }
else alert('저장 실패: ' + (res.error || '알 수 없는 오류'));
})
.catch(function(e) { btn.disabled = false; btn.innerHTML = '&#128190; 저장'; 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 = '&#9650; 접기';
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 = '&#9664; 미리보기';
}
}
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 <img> tags before escaping so they survive escHtml()
var imgPlaceholders = [];
var imgRe = /<img\s+[^>]*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 <img> 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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/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 '<pre class="md-code-block"><code>' + escHtml(trimmed) + '</code></pre>';
}
}
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + widgetId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderMusicBlock(\'' + widgetId + '\', decodeURIComponent(\'' + encoded + '\'))">악보 렌더링</button>' +
'<button onclick="toggleMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<pre class="md-code-block music-source" style="margin:0;border-radius:0;border:none;border-bottom:1px solid var(--line);max-height:120px;overflow-y:auto;font-size:11px"><code>' + escHtml(trimmed) + '</code></pre>' +
'<div class="music-output" style="display:none"></div>' +
'</div>' +
'</div>';
}
if (lang === 'tradingview') {
var tvSymbol = '', tvInterval = 'D';
// code content is already HTML-escaped at this point — unescape before parsing
var tvRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/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 '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var tvId = 'tv-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + tvId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderTVChart(this,\'' + tvId + '-c\',\'' + escHtml(tvSymbol) + '\',\'' + escHtml(tvInterval) + '\')">📈 차트 불러오기</button>' +
'<button onclick="toggleMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<div id="' + tvId + '-c" style="height:440px;width:100%"></div>' +
'</div>' +
'</div>';
}
if (lang === 'kstock') {
var ksRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/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 '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
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 '<div class="music-widget" id="' + ksId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(ksCode) + ' · ' + escHtml(ksPeriodLabel) + ' · 네이버금융</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<a href="' + ksNaverUrl + '" target="_blank" style="font-size:12px;color:var(--muted);text-decoration:none">네이버금융 ↗</a>' +
'<button onclick="toggleMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body" style="padding:8px;text-align:center">' +
'<img src="' + ksImgUrl + '" alt="' + escHtml(ksCode) + ' 차트" style="max-width:100%;border-radius:4px" onerror="this.parentNode.innerHTML=\'<span style=\\\"color:var(--muted);font-size:13px\\\">차트를 불러올 수 없습니다.</span>\'">' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
// Images: ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
var safeAlt = alt.replace(/"/g, '&quot;');
var errAlt = (alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; " + errAlt + "를 불러올 수 없습니다.</span>')";
return '<img class="md-img" src="' + url + '" alt="' + safeAlt + '" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />';
});
// 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, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="yt-embed-wrap"><iframe class="yt-embed" src="' + safeEmbed + '" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">YouTube에서 열기 ↗</a></div></div>' + 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, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="map-embed-wrap"><iframe class="map-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Google 지도에서 열기 ↗</a></div></div>' + post;
});
// Windy URLs in <img src="..."> (model used ![alt](url) syntax) → inline iframe [must run first]
html = html.replace(/<img\s[^>]*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, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
});
// Windy URLs inside anchor tags (markdown links) → inline iframe [must run BEFORE bare-URL regex]
html = html.replace(/<a\s[^>]*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, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
});
// 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, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>' + 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','<span class=&quot;md-img-err&quot;>&#x2716; 이미지를 불러올 수 없습니다.</span>')";
return pre + '<img class="md-img" src="' + url + '" alt="" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />' + post;
});
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// Markdown tables → HTML (line-by-line, O(n), must run BEFORE \n → <br>)
(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 = '<thead><tr>' + headers.map(function(h) { return '<th>' + h + '</th>'; }).join('') + '</tr></thead>';
var tbody = '<tbody>' + tbl.slice(2).map(function(row) {
return '<tr>' + parseRow(row).map(function(c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody>';
out.push('<div class="md-table-wrap"><table class="md-table">' + thead + tbody + '</table></div>');
i = j;
continue;
}
out.push(line);
i++;
}
html = out.join('\n');
})();
// Line breaks
html = html.replace(/\n/g, '<br>');
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 = '<option value="">-- MIDI 기기 선택 --</option>';
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 = '<div class="music-actions">';
if (data.saved_to) {
html += '<div style="font-size:11px;color:var(--muted);margin-bottom:6px;width:100%">💾 ' + escHtml(data.saved_to) + '</div>';
}
if (midiUrl) {
html += '<div class="midi-router-panel" id="' + rid + '" style="display:none;margin-bottom:4px">'
+ '<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>'
+ '<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>'
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/music-app.html?edit=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>';
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
}
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
html += '</div>';
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 = '<div class="music-page-nav" id="' + navId + '" data-page="1" data-total="' + pages + '" data-id="' + baseId + '">' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',-1)" id="' + navId + '-prev" disabled>◀</button>' +
'<span id="' + navId + '-label">1 / ' + pages + '</span>' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',1)" id="' + navId + '-next"' + (pages <= 1 ? ' disabled' : '') + '>▶</button>' +
'</div>';
}
return nav + '<img id="' + navId + '-img" src="' + src1 + '" alt="악보" style="width:100%;height:auto;background:#fff;border-radius:8px;padding:8px;display:block" />';
}
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 = '<span style="color:var(--err);font-size:12px">렌더링 실패: ' + escHtml(e.message) + '</span>';
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
? '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">💾 ' + escHtml(data.saved_to) + '</div>'
: '';
// MIDI player panel (hidden by default) — includes MIDI download
var playerPanel =
'<div id="' + rid + '" class="midi-router-panel" style="display:none;margin-top:8px">' +
'<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>' +
'<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>' +
'<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>' +
'<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(filename) + '" style="padding:2px 8px;font-size:11px">⬇ MIDI</a>' +
'<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' +
'</div>';
// 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 = '<div id="' + wid + '-score" style="margin-top:8px;display:none">' + scoreInner + '</div>';
var scoreBtnLabel = '🎼 악보';
var html =
'<div class="music-widget" id="' + wid + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + escHtml(filename) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body"><div class="music-output">' +
savedLine +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/music-app.html?edit=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
'</div>' +
playerPanel +
scoreArea +
'</div></div>' +
'</div>';
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 = '<div style="font-size:12px;color:var(--muted);padding:8px 0">📄 악보 변환 중... <span id="' + wid + '-sprog"></span></div>';
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 = '<span style="color:var(--err);font-size:12px">악보 변환 실패: ' + escHtml(ev.message) + '</span>';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
}
} catch (e) {
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">오류: ' + escHtml(e.message) + '</span>';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-upload-' + Date.now();
appendChatBubble('system',
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">🎵 <strong>' + escHtml(file.name) + '</strong> 업로드 중...</div>'
);
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 = '<span style="color:var(--err);font-size:12px">업로드 실패: ' + escHtml(e.message) + '</span>';
}
}
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 = '<div class="msg-content">' + html + '</div>';
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 = '&#9664; Preview';
return;
}
// Show loading state
container.style.display = 'flex';
container.innerHTML = '<span style="color:var(--muted);font-size:13px;padding:12px">Generating preview...</span>';
btn.innerHTML = '&#9650; 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 = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview failed: ' + escHtml(data.error || 'No slides found') + '</span>';
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 '<img src="' + src + '" alt="Slide ' + (i + 1) + '" onclick="openImageZoom(this.src)" loading="lazy" />';
}).join('');
});
})
.catch(function(err) {
btn.classList.remove('loading');
container.innerHTML = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview error: ' + escHtml(err.message) + '</span>';
});
}
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 += '<div style="margin-bottom:6px;font-weight:700;font-size:11px">' + escHtml(sn) + '</div>';
previewHtml += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
rows.slice(0, 30).forEach(function(row, ri) {
previewHtml += '<tr>';
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 ?? '')) + '</' + tag + '>';
});
previewHtml += '</tr>';
});
if (rows.length > 30) previewHtml += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 30) + '행 더</td></tr>';
previewHtml += '</table></div>';
});
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 '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📄</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(a.name) + pagesInfo + '">' + escHtml(a.name) + '</span>'
+ (txtPreview ? '<button onclick="toggleStagedTxtPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">txt 미리보기</button>' : '')
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">PDF</button>' : '')
+ (a.txtUrl ? '<a href="' + escHtml(a.txtUrl) + '" download="' + escHtml(a.txtName || '') + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="txt 다운로드">📝⬇</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ (pagesInfo ? '<div style="font-size:10px;color:var(--muted);padding-left:2px">텍스트 추출 완료' + pagesInfo + '</div>' : '')
+ (txtPreview ? '<div id="txt-pdf-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + txtPreview + '</div>' : '')
+ '<div id="pdf-preview-' + a.id + '" style="display:none"><iframe data-src="' + escHtml(a.url || '') + '" style="width:100%;height:400px;border:1px solid var(--line);border-radius:6px"></iframe></div>'
+ '</div>';
}
// TXT 자동저장 카드 (미리보기 + 다운로드)
if (a.type === 'txt-auto') {
var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : '');
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px dashed var(--brand);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📝</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ '<button onclick="toggleStagedTxtPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>'
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="다운로드">⬇ 저장</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '<div id="txt-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + preview + '</div>'
+ '</div>';
}
// excel 카드
if (a.type === 'excel') {
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:360px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📊</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ (a.excelPreviewHtml ? '<button onclick="toggleStagedExcelPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block">⬇ 저장</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ (a.excelPreviewHtml ? '<div id="excel-staged-' + a.id + '" style="display:none;max-height:260px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px">' + a.excelPreviewHtml + '</div>' : '')
+ '</div>';
}
// 기타 카드 (text, csv)
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>' + icon + '</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '</div>';
}).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 = ['<option value="">글로벌 기본값</option>'];
for (const u of data.users) {
const selected = u.username === skillsTargetUser ? ' selected' : '';
opts.push(`<option value="${escHtml(u.username)}"${selected}>${escHtml(u.username)}</option>`);
}
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 = '<div style="padding:12px;color:var(--muted);font-size:11px"><span class="spinner"></span> Loading...</div>';
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 = '<div class="empty-state" style="padding:16px">No skills yet.<br>Click "+ Create" to make one!</div>';
} else {
el.innerHTML = skills.map(s => {
const skillEmoji = (s.emoji && s.emoji !== '🦞' && s.emoji !== '⚠️') ? s.emoji : '🧩';
const globalBadge = s.globalOnly ? '<span style="font-size:9px;opacity:.6;margin-left:4px">[전체]</span>' : '';
return `
<div class="skill-card ${s.enabled ? 'enabled' : ''}" data-skill-id="${escHtml(s.id)}" onclick="toggleSkill('${escHtml(s.id)}')">
<div class="skill-card-top">
<div class="skill-card-emoji">${escHtml(skillEmoji)}</div>
<div class="skill-card-info">
<div class="skill-card-name">${escHtml(s.name)}${globalBadge}</div>
<div class="skill-card-desc">${escHtml(s.description || 'No description')}</div>
</div>
<div class="skill-card-actions">
<button class="skill-mini-btn" onclick="event.stopPropagation();editSkill('${escHtml(s.id)}')" title="Edit" aria-label="Edit skill">✎</button>
<button class="skill-remove-btn" onclick="event.stopPropagation();deleteSkill('${escHtml(s.id)}')" title="Delete" aria-label="Delete skill">✕</button>
<div class="skill-card-dot"></div>
</div>
</div>
</div>
`;
}).join('');
renderSkillEligibility(skills);
}
} catch (err) {
el.innerHTML = `<div style="padding:12px;color:var(--err);font-size:11px">Error: ${err.message}</div>`;
}
}
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 = '<div style="font-size:11px;color:var(--muted);text-align:center;padding:8px">Click messages in chat to select them</div>';
} 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 `<div class="pin-card">
<span>${icon}</span>
<div class="pin-card-text">${escHtml(text)}</div>
<button class="pin-card-remove" onclick="removePinFromList(${idx})">&times;</button>
</div>`;
}).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 = '<div class="empty-state" style="text-align:center;color:var(--muted);padding:40px 20px">No schedules yet. <strong>+ 새 일정</strong> to get started.</div>';
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 `
<div style="display:flex;align-items:start;justify-content:space-between;gap:12px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;cursor:pointer" onclick="editSchedule('${job.id}')">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<div style="font-weight:700;font-size:13px">${job.name || 'Untitled'}</div>
<span class="${statusClass}" style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px;${job.status === 'active' ? 'background:#c8f0c4;color:#0d5c2f' : job.status === 'paused' ? 'background:#fff4d6;color:#7d5700' : 'background:#ffd6d6;color:#8b0000'}">${job.status || 'unknown'}</span>
</div>
<div style="font-size:12px;color:var(--muted);line-height:1.4;margin-bottom:6px">${job.prompt ? job.prompt.slice(0, 60) + (job.prompt.length > 60 ? '...' : '') : 'No prompt'}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:11px;color:var(--muted)">
<div><strong>Next:</strong> ${nextRun}</div>
<div><strong>Last:</strong> ${lastRun}</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;flex-shrink:0">
<button onclick="event.stopPropagation(); runScheduleNow('${job.id}')" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Run now">Run Now</button>
<button onclick="event.stopPropagation(); toggleSchedulePause('${job.id}')" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Toggle pause">${job.status === 'active' ? 'Pause' : 'Resume'}</button>
<button onclick="event.stopPropagation(); deleteSchedule('${job.id}')" style="border:1px solid #ff6b6b;background:#ffe0e0;color:#8b0000;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Delete">삭제</button>
</div>
</div>
`;
}).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
setSidebarTab('jobs');
// ---------------------------------------------------------------------------
// 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 openNewBgTaskModal() {
const modal = document.getElementById('bgt-new-task-modal');
if (modal) { modal.style.display = 'flex'; document.getElementById('bgt-new-title').focus(); }
}
function closeNewBgTaskModal() {
const modal = document.getElementById('bgt-new-task-modal');
if (modal) modal.style.display = 'none';
document.getElementById('bgt-new-title').value = '';
document.getElementById('bgt-new-prompt').value = '';
}
async function submitNewBgTask() {
const title = document.getElementById('bgt-new-title').value.trim();
const prompt = document.getElementById('bgt-new-prompt').value.trim();
if (!title || !prompt) { alert('제목과 작업 내용을 입력해주세요.'); return; }
const btn = document.getElementById('bgt-new-submit-btn');
btn.textContent = '시작 중...'; btn.disabled = true;
try {
const data = await api('/api/bg-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, prompt }) });
if (data.success) {
closeNewBgTaskModal();
await refreshBgTasks();
} else {
alert('태스크 생성 실패: ' + (data.error || 'unknown'));
}
} catch (err) {
alert('오류: ' + err.message);
} finally {
btn.textContent = '▶ 시작'; btn.disabled = false;
}
}
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 = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<span style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;color:${col.color}">${col.label}</span>
<span style="font-size:10px;background:${col.bg};color:${col.color};border-radius:999px;padding:1px 8px;font-weight:700">${tasks.length}</span>
</div>
<div class="bgt-col-cards" style="display:flex;flex-direction:column;gap:8px;min-height:60px">${tasks.map(t => bgtCardHTML(t, col)).join('')}</div>
`;
board.appendChild(colEl);
if (tasks.length === 0) {
// Empty placeholder
colEl.querySelector('.bgt-col-cards').innerHTML = `<div style="text-align:center;padding:20px 8px;color:var(--muted);font-size:11px;border:1.5px dashed var(--line);border-radius:10px">No ${col.label.split(' ').slice(1).join(' ').toLowerCase()} tasks</div>`;
}
}
// If no tasks at all
if (bgtTasks.length === 0) {
board.innerHTML = `<div style="text-align:center;padding:60px 24px;color:var(--muted);font-size:13px;flex:1">
<div style="font-size:36px;margin-bottom:12px">작업</div>
<div style="font-weight:700;margin-bottom:4px">No background tasks yet</div>
<div style="font-size:12px">Ask CherryClaw to do something in the background and it'll appear here.</div>
</div>`;
}
}
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 `
<div onclick="openBgtPanel('${escHtml(t.id)}')" data-bgt-id="${escHtml(t.id)}" style="
background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 14px;
cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s;
${bgtOpenTaskId === t.id ? 'border-color:var(--brand);box-shadow:0 0 0 2px rgba(90,145,255,0.15);' : ''}
" onmouseover="this.style.borderColor='var(--brand)'" onmouseout="this.style.borderColor='${bgtOpenTaskId===t.id?'var(--brand)':'var(--line)'}'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:6px">
<span style="font-size:13px;font-weight:700;line-height:1.3;flex:1;min-width:0">${escHtml(t.title || 'Untitled Task')}</span>
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<button
onclick="event.stopPropagation(); bgtDeleteTask('${escHtml(t.id)}', '${escHtml(t.status)}')"
title="Remove task"
style="border:1px solid #fca5a5;background:#fff0f0;color:#9c1a1a;border-radius:7px;padding:1px 6px;font-size:11px;font-weight:700;cursor:pointer"
>✕</button>
<span style="font-size:14px;flex-shrink:0">${STATUS_ICON[t.status] || '?'}</span>
</div>
</div>
${currentStep ? `<div style="font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.4">Step ${(t.currentStepIndex||0)+1}/${totalSteps}: ${escHtml(currentStep.description.slice(0,60))}${currentStep.description.length>60?'…':''}</div>` : ''}
${totalSteps > 0 ? `
<div style="background:var(--line);border-radius:999px;height:3px;margin-bottom:8px;overflow:hidden">
<div style="background:${col.color};width:${pct}%;height:100%;border-radius:999px;transition:width 0.4s"></div>
</div>` : ''}
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:10px;color:var(--muted)">${channel} ${timeAgo}</span>
${t.status === 'needs_assistance' ? `<span style="font-size:10px;background:#f5eeff;color:#6d2d9e;border-radius:999px;padding:1px 8px;font-weight:700">Needs you</span>` : ''}
${t.status === 'paused' ? `<span style="font-size:10px;background:#f5f5f5;color:#555;border-radius:999px;padding:1px 8px;font-weight:700">${t.pauseReason || 'paused'}</span>` : ''}
</div>
</div>`;
}
// --- 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 `<div style="display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--line);${isCurrent?'background:#f0f5ff;margin:0 -8px;padding:7px 8px;border-radius:6px;':''}"
title="${step.notes ? escHtml(step.notes) : ''}">
<span style="font-size:14px;flex-shrink:0;line-height:1.4">${icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:${isCurrent?'700':'500'};color:${isCurrent?'var(--text)':'var(--muted)'}">${escHtml(step.description)}</div>
${step.notes ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${escHtml(step.notes.slice(0,120))}</div>` : ''}
</div>
</div>`;
}).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 `<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);font-size:11px">
<span style="color:var(--muted);flex-shrink:0;min-width:52px">${time}</span>
<span style="color:${tc};flex-shrink:0;font-weight:700;min-width:60px">${entry.type}</span>
<span style="color:var(--text);flex:1;min-width:0;word-break:break-word">${escHtml(entry.content)}</span>
</div>`;
}).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 = '<div style="background:#f5eeff;border:1px solid #cba8f5;border-radius:10px;padding:12px 14px;font-size:12px;line-height:1.6;color:#6d2d9e">'
+ '<div style="font-weight:800;margin-bottom:6px">⚠️ Task needs your input</div>'
+ '<div style="color:#3d1a6e;margin-bottom:' + (pauseDetail ? '6px' : '0') + '">' + pauseMsg + '</div>'
+ (pauseDetail ? '<div style="font-size:11px;color:#6d2d9e;opacity:0.8;margin-bottom:4px">' + pauseDetail + '</div>' : '')
+ '</div>'
+ '<div style="border:1px solid #cba8f5;border-radius:10px;overflow:hidden;background:#faf5ff;margin-top:8px">'
+ '<div style="padding:8px 12px;font-size:11px;font-weight:700;color:#6d2d9e;border-bottom:1px solid #e9d8ff">💬 Reply to agent</div>'
+ '<div style="display:flex;gap:8px;padding:8px">'
+ '<textarea id="task-reply-input" rows="2" placeholder="Type your reply — the agent will see this and continue..." style="flex:1;resize:none;border:1px solid #cba8f5;border-radius:8px;padding:8px;font-size:12px;font-family:inherit;background:#fff;color:var(--text)"></textarea>'
+ '<button id="task-reply-send" data-taskid="' + tid + '" style="background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-end">전송</button>'
+ '</div></div>';
}
body.innerHTML = `
<!-- Status row -->
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<span style="font-size:11px;background:var(--line);border-radius:999px;padding:2px 10px;font-weight:700;color:${sc}">${STATUS_ICON[task.status]} ${task.status.replace(/_/g,' ')}</span>
<span style="font-size:11px;color:var(--muted)">${channel}</span>
<span style="font-size:11px;color:var(--muted)">Started ${startedAt}</span>
</div>
<!-- Summary if complete -->
${task.finalSummary ? `<div style="background:#efffea;border:1px solid #b2dfb2;border-radius:10px;padding:12px 14px;font-size:12px;line-height:1.6;color:#1a6e35">
<div style="font-weight:800;margin-bottom:4px">✓ Summary</div>
${escHtml(task.finalSummary)}
</div>` : ''}
<!-- Needs assistance -->
${assistanceHTML}
<!-- Stats row -->
<div style="display:flex;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:100px;background:var(--panel-2);border-radius:10px;padding:10px 12px">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;font-weight:700;margin-bottom:2px">Progress</div>
<div style="font-size:18px;font-weight:800">${stepsDone}<span style="font-size:12px;color:var(--muted)">/${plan.length}</span></div>
<div style="font-size:10px;color:var(--muted)">steps done</div>
</div>
<div style="flex:1;min-width:100px;background:var(--panel-2);border-radius:10px;padding:10px 12px">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;font-weight:700;margin-bottom:2px">Last Tool</div>
<div style="font-size:12px;font-weight:700;word-break:break-all">${escHtml(lastTool.slice(0,30))}</div>
<div style="font-size:10px;color:var(--muted)">${lastToolAt}</div>
</div>
</div>
<!-- Plan steps -->
${plan.length > 0 ? `
<div>
<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:8px">Execution Plan</div>
<div style="border:1px solid var(--line);border-radius:10px;padding:6px 8px">${stepsHTML}</div>
</div>` : ''}
<!-- Process journal -->
<div>
<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:6px">Process Log <span style="font-weight:400;opacity:0.6">(latest first)</span></div>
<div style="font-family:monospace;font-size:11px;border:1px solid var(--line);border-radius:10px;padding:6px 8px;max-height:300px;overflow-y:auto">
${journalHTML || '<div style="color:var(--muted);padding:8px">No entries yet.</div>'}
</div>
</div>
<!-- Delete button -->
${(task.status === 'complete' || task.status === 'failed') ? `
<button onclick="bgtDeleteTask('${escHtml(task.id)}')" style="border:1px solid #fca5a5;background:#fff0f0;color:#9c1a1a;border-radius:8px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-start">Remove</button>` : ''}
`;
// 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 = `<div style="font-size:13px;font-weight:800;margin-bottom:4px">${escHtml(title)}</div>${body ? `<div style="font-size:12px;color:var(--muted);line-height:1.4">${escHtml(body)}</div>` : ''}`;
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 = '<div style="color:var(--muted);font-style:italic;padding:8px 0">No MCP servers configured yet.<br>Add one above or click a preset on the right.</div>';
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 `<div style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;background:${s.enabled ? '#fff' : '#fafafa'}">
<div style="padding-top:2px">
<div style="width:8px;height:8px;border-radius:50%;background:${statusColor};flex-shrink:0"></div>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:12px;color:var(--text)">${escHtml(s.name)} <span style="font-weight:400;color:var(--muted);font-size:11px">(${escHtml(s.id)})</span></div>
<div style="font-size:11px;color:${statusColor};margin-top:1px" title="${escHtml(toolTip)}">${statusLabel}${s.error ? ` — ${escHtml(s.error.slice(0,60))}` : ''}</div>
${toolTip ? `<div style="font-size:10px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(toolTip)}">${escHtml(toolTip.slice(0,80))}${toolTip.length>80?'…':''}</div>` : ''}
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
${s.status === 'connected'
? `<button onclick="disconnectMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:#fff;font-size:10px;cursor:pointer">Disconnect</button>`
: `<button onclick="connectMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--brand);border-radius:5px;background:#fff;color:var(--brand);font-size:10px;font-weight:600;cursor:pointer">Connect</button>`
}
<button onclick="editMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:#fff;font-size:10px;cursor:pointer">Edit</button>
<button onclick="deleteMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid #fecaca;border-radius:5px;background:#fff;color:#ef4444;font-size:10px;cursor:pointer">?</button>
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div style="color:#ef4444">Failed to load: ${escHtml(e.message)}</div>`;
}
}
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 = `<strong>${escHtml(opt.icon || '')} ${escHtml(opt.label)}</strong>${opt.description ? `<br><span style="font-weight:400;opacity:0.75">${escHtml(opt.description)}</span>` : ''}`;
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: 1025px)').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 `<div class="ct-tab${a?' active':''}" onclick="switchChatTermShell('${s.id}')"><svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg><span>${escHtml(s.name)}</span>${chatTermShells.length>1?`<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">&times;</span>`:''}</div>`;
}).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 = '<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 중...</div>';
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 = '<div style="grid-column:1/-1;color:var(--muted);font-size:11px;padding:8px;text-align:center">이미지 추출 후 표시됩니다</div>';
return;
}
grid.innerHTML = _pptxImages.map((img, idx) => `
<div style="position:relative;border:1px solid var(--line);border-radius:6px;overflow:hidden;cursor:pointer;aspect-ratio:4/3;background:var(--panel-2)"
onclick="pptxImageClick(${idx})" title="${escHtml(img.label)}">
<img src="${escHtml(img.url)}" style="width:100%;height:100%;object-fit:cover" loading="lazy"
onerror="this.parentNode.style.background='var(--panel)';this.style.display='none'">
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.5);color:#fff;font-size:9px;padding:2px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(img.label)}</div>
</div>`).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 = '<div style="color:var(--muted);font-size:11px;padding:8px">슬라이드가 없습니다</div>';
return;
}
container.innerHTML = slides.map((s, i) => {
const icon = SLIDE_TYPE_ICON[s.type] || '📄';
const imgThumb = s.image_url
? `<img src="${escHtml(s.image_url)}" style="width:40px;height:30px;object-fit:cover;border-radius:4px;flex-shrink:0" loading="lazy">`
: `<div style="width:40px;height:30px;border-radius:4px;background:var(--panel);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px">🖼</div>`;
const bulletsPreview = Array.isArray(s.bullets) && s.bullets.length
? `<div style="font-size:10px;color:var(--muted);margin-top:3px;overflow:hidden;max-height:32px">${s.bullets.slice(0,2).map(b => `• ${b}`).join('<br>')}</div>`
: '';
return `<div class="psb-slide-card" style="border:1px solid var(--line);border-radius:8px;padding:7px 8px;background:var(--panel-2);cursor:pointer" onclick="pptxToggleEdit(${i})">
<div style="display:flex;align-items:center;gap:7px">
<span style="font-size:10px;color:var(--muted);flex-shrink:0;width:18px;text-align:right">${i+1}</span>
${imgThumb}
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${icon} ${escHtml(s.title || '(제목 없음)')}</div>
<div style="font-size:10px;color:var(--muted)">${s.type}</div>
${bulletsPreview}
</div>
</div>
<div id="psb-edit-${i}" style="display:none;margin-top:7px;border-top:1px solid var(--line);padding-top:7px">
<input type="text" value="${escHtml(s.title || '')}" oninput="pptxEditTitle(${i}, this.value)"
style="width:100%;box-sizing:border-box;font-size:12px;padding:4px 6px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;margin-bottom:4px">
${Array.isArray(s.bullets) ? `<textarea rows="3" oninput="pptxEditBullets(${i}, this.value)"
style="width:100%;box-sizing:border-box;font-size:11px;padding:4px 6px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;resize:vertical;font-family:'IBM Plex Mono',monospace">${escHtml((s.bullets||[]).join('\n\n'))}</textarea>` : ''}
</div>
</div>`;
}).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 = '<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 후 표시됩니다</div>';
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');
}