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) => `
`).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 = [];
let isFreshDevice = false;
if (chatSessions.length === 0) {
isFreshDevice = true;
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();
// 새 기기에서 처음 열었다면, 다른 기기에서 이어가던 대화로 자동 복귀
if (isFreshDevice) resumeMainSessionPointer();
}
// 메인챗의 "마지막 활성 세션"을 서버에 기록 — 다른 기기에서 새로 열었을 때 이어보기용
function updateMainSessionPointer(id) {
if (!id) return;
api('/api/user/app-sessions', { method: 'PUT', body: JSON.stringify({ appKey: 'main', sessionId: id }) }).catch(() => {});
}
async function resumeMainSessionPointer() {
try {
const d = await api('/api/user/app-sessions');
const ptr = d && d.main;
if (!ptr) { updateMainSessionPointer(activeChatSessionId); return; } // 최초 로그인: 이 세션을 기준점으로 등록
if (ptr === activeChatSessionId) return;
const cur = chatSessions.find(s => s.id === activeChatSessionId);
if (cur && cur.history && cur.history.length > 0) return; // 이미 이 기기에서 대화가 시작됐으면 건드리지 않음
const hd = await api('/api/chat/sessions/' + encodeURIComponent(ptr));
if (!hd || !hd.success || !Array.isArray(hd.history) || hd.history.length === 0) return;
// 비어있던 자리표시자 세션은 제거하고 포인터가 가리키는 세션으로 전환
chatSessions = chatSessions.filter(s => s.id !== activeChatSessionId);
let existing = chatSessions.find(s => s.id === ptr);
if (!existing) {
existing = { id: ptr, title: makeSessionTitle(hd.history), history: hd.history, processLog: [], createdAt: hd.createdAt || Date.now(), updatedAt: hd.lastActiveAt || Date.now() };
chatSessions.unshift(existing);
} else {
existing.history = hd.history;
delete existing.serverOnly;
}
activeChatSessionId = ptr;
setAgentSessionId(ptr);
saveChatSessions();
syncActiveChat();
} catch { /* 서버 미응답 시 무시 */ }
}
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();
updateMainSessionPointer(id);
}
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();
updateMainSessionPointer(activeChatSessionId);
// 서버 세션 파일도 삭제 (재로드 시 재등장 방지)
api(`/api/chat/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
function openSession(id) {
activeChatSessionId = id;
setAgentSessionId(id);
updateMainSessionPointer(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 `
${sectionType === 'completed' ? '' : '\u2261'}
${escHtml(job.name)}
${scheduleText}
Next: ${nextRunText}
Last: ${lastRunText}
${resultSnippet && !isOk ? `${escHtml(resultSnippet)} ` : ''}
${isOk ? '\u2713 OK ' : ''}
${job.status !== 'running' ? `\u25b6 ` : 'Running... '}
\u270e
${job.status === 'paused' ? '\u25b6' : '\u23f8'}
\u2715
${job.sessionId ? `\u2192 ` : ''}
`;
}
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 = ``;
}
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 `
...
Thought process
${wordCount} words
${escHtml(thinking)}
`;
}
function toggleThink(id) {
const btn = document.getElementById(id + '_btn');
const body = document.getElementById(id + '_body');
if (!btn || !body) return;
const open = body.style.display !== 'none' && body.style.display !== '';
body.style.display = open ? 'none' : 'block';
btn.classList.toggle('open', !open);
}
function renderReactSteps(steps) {
if (!steps || steps.length === 0) return '';
const toolSteps = steps.filter(s => s.action);
if (toolSteps.length === 0) return '';
const getSearchDiagnostics = (s) => s?.toolData?.search_diagnostics || s?.diagnostics || null;
const extractUrls = (s, resultText) => {
const fromToolData = Array.isArray(s?.toolData?.results)
? s.toolData.results.map(r => String(r?.url || '').trim()).filter(u => /^https?:\/\//i.test(u))
: [];
const fromText = String(resultText || '').match(/https?:\/\/[^\s)]+/g) || [];
const merged = [...fromToolData, ...fromText.map(u => String(u).trim())];
const out = [];
const seen = new Set();
for (const u of merged) {
if (!u || seen.has(u)) continue;
seen.add(u);
out.push(u);
if (out.length >= 5) break;
}
return out;
};
const providerSummary = (diag) => {
if (!diag || !Array.isArray(diag.attempted) || !diag.attempted.length) return '';
return diag.attempted.map(a => {
const p = String(a.provider || '').toLowerCase();
const status = String(a.status || '').toLowerCase();
if (status === 'success') {
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
return `${p}=success${count}`;
}
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
}).join(' | ');
};
const id = 'steps_' + Math.random().toString(36).slice(2);
const items = toolSteps.map(s => {
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
const diag = String(s.action || '') === 'web_search' ? getSearchDiagnostics(s) : null;
const query = String(diag?.query || s?.params?.query || '').trim();
const selectedProvider = String(diag?.selected_provider || s?.toolData?.provider || '').toLowerCase();
const providers = diag ? providerSummary(diag) : '';
const urls = String(s.action || '') === 'web_search' ? extractUrls(s, resultText) : [];
return `
${s.thought ? `
${escHtml(s.thought.slice(0, 120))}
` : ''}
${escHtml(s.action)}
${resultText ? `
${escHtml(resultText.slice(0, 160))}${resultText.length > 160 ? '…' : ''}
` : ''}
${diag ? `
${query ? `
query : ${escHtml(query)}
` : ''}
${selectedProvider ? `
provider hit : ${escHtml(selectedProvider)}
` : ''}
${providers ? `
providers : ${escHtml(providers)}
` : ''}
` : ''}
${String(s.action || '') === 'web_search' && !diag ? `
${selectedProvider ? `
provider hit : ${escHtml(selectedProvider)}
` : ''}
` : ''}
${urls.length ? `
sources :
${urls.slice(0, 3).map((u, i) => `
`).join('')}
` : ''}
`;
}).join('');
return `
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
${items}
`;
}
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(`
`);
} else {
buttons.push(`
⬇ ${escHtml(filename)} `);
}
}
return buttons.length ? `${buttons.join(' ')}
` : '';
}
function renderAssistantContent(content) {
const text = String(content || '');
const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text);
if (!hasStaged) return `${renderMarkdown(text)}
`;
const chunks = text
.split(/\n\s*---\s*\n/g)
.map(s => String(s || '').trim())
.filter(Boolean);
const html = chunks.map((chunk, idx) => {
const m = chunk.match(/^(Initial chat|Execution result|Final chat):\s*\n?/i);
const title = m ? m[1] : `Section ${idx + 1}`;
const body = m ? chunk.slice(m[0].length).trim() : chunk;
return `
${escHtml(title)}
${renderMarkdown(body)}
`;
}).join('');
return `${html}
`;
}
function renderArtifacts(artifacts) {
const rows = Array.isArray(artifacts) ? artifacts : [];
if (!rows.length) return '';
const cards = rows.map((a, idx) => {
const type = String(a?.type || '').trim();
const title = String(a?.title || type || `Artifact ${idx + 1}`).trim();
const status = String(a?.status || 'ok').toLowerCase();
const pathVal = String(a?.path || '').trim();
const fromPath = String(a?.from_path || '').trim();
const toPath = String(a?.to_path || '').trim();
const summary = String(a?.summary || '').trim();
const preview = String(a?.preview || '').trim();
const files = Array.isArray(a?.files) ? a.files.slice(0, 12).map(x => String(x || '')).filter(Boolean) : [];
const dirs = Array.isArray(a?.directories) ? a.directories.slice(0, 8).map(x => String(x || '')).filter(Boolean) : [];
const locationTarget = toPath || pathVal || fromPath;
return `
${escHtml(title)}
${escHtml(status)}
${pathVal ? `
Path: ${escHtml(pathVal)}
` : ''}
${fromPath ? `
From: ${escHtml(fromPath)}
` : ''}
${toPath ? `
To: ${escHtml(toPath)}
` : ''}
${summary ? `
${escHtml(summary)}
` : ''}
${files.length ? `
Files: ${escHtml(files.join(', '))}${Array.isArray(a?.files) && a.files.length > files.length ? ' ...' : ''}
` : ''}
${dirs.length ? `
Directories: ${escHtml(dirs.join(', '))}${Array.isArray(a?.directories) && a.directories.length > dirs.length ? ' ...' : ''}
` : ''}
${preview ? `
${escHtml(preview)}
` : ''}
${locationTarget ? `
Open In File Location
` : ''}
`;
}).join('');
return `${cards}
`;
}
async function openInFileLocation(targetPath) {
const p = String(targetPath || '').trim();
if (!p) return;
try {
const res = await fetch('/api/open-path', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path: p }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data?.ok) {
throw new Error(String(data?.error || `HTTP ${res.status}`));
}
addProcessEntry('info', `Opened in file location: ${p}`);
} catch (err) {
addProcessEntry('error', `Open path failed: ${String(err?.message || err)}`);
}
}
function renderChatMessages() {
const container = document.getElementById('chat-messages');
if (chatHistory.length === 0) {
container.innerHTML = `
CherryClaw
Chat directly with your local model. No API keys, no cloud — just you and the model.
Enable Agent mode when you want planning + tool execution in this chat.
`;
return;
}
container.innerHTML = chatHistory.map((msg, idx) => {
const pinClass = contextPinMode ? ' pin-selectable' : '';
const selectedClass = (contextPinMode && pinnedMessageIndices.has(idx)) ? ' pin-selected' : '';
// Also show green for confirmed pins even outside pin mode
const confirmedClass = (!contextPinMode && confirmedPins.some(p => p.content === msg.content && p.role === (msg.role === 'user' ? 'user' : 'assistant'))) ? ' pin-selected' : '';
const clickHandler = contextPinMode ? ` onclick="togglePinMessage(${idx})"` : '';
if (msg.role === 'widget') {
return ``;
}
return `
${msg.role === 'user' ? 'U' : '
'}
${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}${msg.role === 'ai' ? ` 🔊 ` : ''}
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `
${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}
`}
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''}
${msg.role === 'ai' ? renderThinkBlock(msg.thinking) : ''}
${msg.role === 'ai' ? renderReactSteps(msg.steps) : ''}
`;
}).join('');
validateDownloadLinks(container);
if (isThinking && streamingSessionId === activeChatSessionId) {
const progressHtml = currentProgressLines.length
? `
${currentProgressLines.map((l) => `
• ${escHtml(l)}
`).join('')}
`
: '';
container.innerHTML += `
CherryClaw${useAgentMode ? ' (Agent)' : ''}
${currentPreflightStatus ? `
${escHtml(currentPreflightStatus)}
` : ''}
${progressHtml}
`;
}
container.scrollTop = container.scrollHeight;
}
function pushProgressLine(line) {
const txt = String(line || '').trim();
if (!txt) return;
const last = currentProgressLines[currentProgressLines.length - 1] || '';
if (last === txt) return;
currentProgressLines.push(txt);
if (currentProgressLines.length > 8) currentProgressLines = currentProgressLines.slice(-8);
if (isThinking) renderChatMessages();
}
// ---- Process log ----
function renderProcessPill(entries) {
const id = 'proc_' + Math.random().toString(36).slice(2);
return `
Process
${formatProcessLines(entries)}
`;
}
function formatProcessLines(entries) {
if (!entries || entries.length === 0) return 'No process details.
';
const TYPE_COLORS = {
user: 'var(--text)', think: '#388bfd', tool: '#e3b341',
result: '#bc8cff', error: 'var(--red)', final: '#56d364',
warn: 'var(--yellow)', step: '#56d364', info: '#79c0ff',
split: 'var(--accent2)', synth: 'var(--accent2)',
};
return entries.map(e => {
const rawType = String(e.type || 'info');
const typeLabel = escHtml(rawType).toUpperCase();
const typeColor = TYPE_COLORS[rawType] || 'var(--muted)';
const rawContent = String(e.content || '');
// Render markdown for result entries so download links show as clickable buttons
const content = rawType === 'result' ? renderMarkdown(rawContent) : escHtml(rawContent);
return `[${escHtml(String(e.ts || ''))}] ${typeLabel} ${content}
`;
}).join('');
}
function toggleCurrentProcess() {
const panel = document.getElementById('current-turn-process');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
if (!open) {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
panel.innerHTML = formatProcessLines(turnEntries);
}
}
function isNearBottom(el, threshold = 40) {
if (!el) return true;
return (el.scrollHeight - (el.scrollTop + el.clientHeight)) <= threshold;
}
function setupProcessAndRightScrollTracking() {
const processEl = document.getElementById('process-log');
if (processEl && !processEl.dataset.scrollBound) {
processEl.dataset.scrollBound = '1';
processEl.addEventListener('scroll', () => {
processLogAutoFollow = isNearBottom(processEl, 36);
});
}
const rightEl = document.getElementById('right-panel');
if (rightEl && !rightEl.dataset.scrollBound) {
rightEl.dataset.scrollBound = '1';
rightEl.addEventListener('scroll', () => {
rightColumnAutoFollow = isNearBottom(rightEl, 28);
});
}
}
function maybeAutoScrollRightColumn(force = false) {
const rightEl = document.getElementById('right-panel');
if (!rightEl) return;
if (force || rightColumnAutoFollow || isThinking) {
rightEl.scrollTop = rightEl.scrollHeight;
}
}
function updateHeartbeatUI() {
const stateEl = document.getElementById('r-heartbeat-state');
const metaEl = document.getElementById('r-heartbeat-meta');
if (stateEl) {
const state = String(lastHeartbeat.state || 'idle').toLowerCase();
stateEl.textContent = state || 'idle';
stateEl.style.color = state === 'running' || state === 'retrying'
? 'var(--warn)'
: (state === 'stalled' ? 'var(--err)' : 'var(--text)');
}
if (metaEl) {
const step = String(lastHeartbeat.current_step || '-');
const retries = Number(lastHeartbeat.retry_count || 0);
const fv = Number(lastHeartbeat.format_violation_count || 0);
const lvl = String(lastHeartbeat.level || '').toLowerCase();
const lvlTag = lvl ? ` ${lvl}` : '';
metaEl.textContent = `step:${step} • retries:${retries} • fv:${fv}${lvlTag}`;
}
}
function clearProcessLog() {
processLogEntries.length = 0;
const el = document.getElementById('process-log');
if (el) el.innerHTML = '활동 대기 중...
';
processLogAutoFollow = true;
rightColumnAutoFollow = true;
maybeAutoScrollRightColumn(true);
persistActiveChat();
}
function addProcessEntry(type, content, extra) {
const ts = new Date().toLocaleTimeString();
processLogEntries.push({ ts, type, content, extra });
renderProcessLog();
maybeAutoScrollRightColumn(isThinking || rightColumnAutoFollow);
persistActiveChat();
if (isThinking) {
const panel = document.getElementById('current-turn-process');
if (panel && panel.style.display !== 'none' && currentTurnStartIndex >= 0) {
panel.innerHTML = formatProcessLines(processLogEntries.slice(currentTurnStartIndex));
}
}
}
function renderProcessLog() {
const el = document.getElementById('process-log');
if (!el) return;
setupProcessAndRightScrollTracking();
const shouldFollow = processLogAutoFollow || isThinking;
if (processLogEntries.length === 0) {
el.innerHTML = '활동 대기 중...
';
if (shouldFollow) el.scrollTop = el.scrollHeight;
return;
}
el.innerHTML = processLogEntries.map((e,i) => {
let icon, color, label, labelColor;
switch(e.type) {
case 'user': icon='?'; color='var(--accent2)'; labelColor='var(--text)'; label='USER'; break;
case 'think': icon='??'; color='#388bfd'; labelColor='#388bfd'; label='THINK'; break;
case 'tool': icon='?'; color='#e3b341'; labelColor='#e3b341'; label='TOOL'; break;
case 'result': icon='?'; color='#bc8cff'; labelColor='#bc8cff'; label='OK'; break;
case 'error': icon='?'; color='var(--red)'; labelColor='var(--red)'; label='FAIL'; break;
case 'final': icon='?'; color='#56d364'; labelColor='#56d364'; label='FINAL'; break;
case 'warn': icon='?'; color='var(--yellow)'; labelColor='var(--yellow)'; label='WARN'; break;
case 'step': icon='?'; color='#56d364'; labelColor='#56d364'; label='STEP'; break;
case 'split': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SPLIT'; break;
case 'synth': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SYNTH'; break;
case 'info': icon='·'; color='#79c0ff'; labelColor='#79c0ff'; label='INFO'; break;
default: icon='·'; color='var(--muted)'; labelColor='var(--muted)'; label=e.type; break;
}
// Think entries: show full content in a scrollable block
if (e.type === 'think' && e.content && e.content.length > 60) {
const id = 'pl_' + Math.random().toString(36).slice(2,8);
const preview = e.content.slice(0, 80).replace(/\n/g,' ');
return `
${e.ts}
${icon} ${label}
${escHtml(preview)}…
${escHtml(e.content)}
`;
}
// Tool result entries: collapsible if long
if (e.type === 'result' && e.content && e.content.length > 120) {
const id = 'pl_' + Math.random().toString(36).slice(2,8);
const preview = e.content.slice(0, 100).replace(/\n/g,' ');
return `
${e.ts}
${icon} ${label}
${escHtml(preview)}… [expand]
${escHtml(e.content)}
`;
}
// Memory suggestion: show approve/reject buttons
if (e.type === 'memory') {
const id = 'pl_mem_' + i;
const suggest = e.extra || {};
return `
${e.ts}
${icon} ${label}
${escHtml(String(e.content || ''))}
Approve
Reject
`;
}
// If an 'extra' object/array was provided, show a details toggle
if (e.extra) {
const id = 'pl_extra_' + i;
const pretty = escHtml(JSON.stringify(e.extra, null, 2));
return `
${e.ts}
${icon} ${label}
${escHtml(String(e.content || ''))}
[details]
${pretty}
`;
}
return `${e.ts} ${icon} ${label} ${escHtml(String(e.content || ''))}
`;
}).join('');
if (shouldFollow) el.scrollTop = el.scrollHeight;
}
function togglePL(id) {
const el = document.getElementById(id);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function isFailedTurnReply(text) {
const t = String(text || '').trim().toLowerCase();
if (!t) return true;
if (/^blocked\b/.test(t)) return true;
if (/^no response received\.?$/.test(t)) return true;
if (/^connection error:/.test(t)) return true;
if (/^sorry, i didn'?t catch that/.test(t)) return true;
if (/could not|couldn't/.test(t) && /(verify|format|synthes|extract|read|write|tool|request)/.test(t)) return true;
return false;
}
// ---- Send chat (SSE version) ----
async function sendChat(queuedMessage = null) {
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value;
const message = String(raw || '').trim();
if (!message && stagedAttachments.length === 0) return;
if (isThinking) {
if (typeof queuedMessage === 'string') return;
if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
addProcessEntry('warn', `Queue full (${MAX_QUEUED_PROMPTS}). Wait for current run to finish.`);
return;
}
queuedPrompts.push(message);
addProcessEntry('info', `Queued prompt #${queuedPrompts.length}. It will run automatically next.`);
input.value = '';
input.style.height = 'auto';
updateQueuedPromptUI();
return;
}
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
let fullMessage = message;
let displayMessage = message;
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
const attachParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
.map(function(a) {
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
});
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : '');
const compactParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; })
.map(function(a) {
// [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링
var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**';
// PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가
if (a.type === 'pdf' && a.txtUrl) {
line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')';
}
return line;
});
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : '');
clearStagedAttachments();
}
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
persistActiveChat();
if (typeof queuedMessage !== 'string') {
input.value = '';
input.style.height = 'auto';
}
isThinking = true;
thinkingStartedAt = Date.now();
if (thinkingTicker) clearInterval(thinkingTicker);
thinkingTicker = setInterval(() => {
const el = document.getElementById('thinking-elapsed');
if (el) el.textContent = `${Math.round((Date.now() - thinkingStartedAt) / 1000)}s 경과`;
}, 1000);
streamingSessionId = activeChatSessionId; // lock bubble to this session
setButtonState(true);
currentPreflightStatus = '';
currentProgressLines = [];
liveTaskSteps = [];
if (typeof _v2LiveState !== 'undefined') { _v2LiveState.fileId = null; _v2LiveState.inBlock = false; }
lastHeartbeat = {
state: 'running',
level: '',
current_step: 'dispatch',
retry_count: 0,
format_violation_count: 0,
message: 'Turn started',
};
lastHeartbeatLogSignature = '';
processLogAutoFollow = true;
rightColumnAutoFollow = true;
updateHeartbeatUI();
renderLiveStepsPanel();
sendBtn.disabled = false;
updateQueuedPromptUI();
renderChatMessages();
maybeAutoScrollRightColumn(true);
currentTurnStartIndex = processLogEntries.length;
addProcessEntry('user', message);
// 서버(session.ts)가 세션 히스토리를 디스크에 영구 저장하므로 클라이언트 history 전송 불필요.
// historyOverride를 보내면 서버 세션을 덮어써서 오히려 오작동 + 대용량 413 원인이 됨.
const historyForAPI = undefined;
const allSteps = [];
let finalReply = '';
let finalArtifacts = [];
const turnThinkingBuffer = [];
const turnThinkingSeen = new Set();
let sawExecuteModeThisTurn = false;
let sawToolActivityThisTurn = false;
let turnFileLinks = [];
const collectTurnThinking = (value) => {
const text = String(value || '').trim();
if (!text) return;
const key = text.replace(/\s+/g, ' ').trim();
if (!key || turnThinkingSeen.has(key)) return;
turnThinkingSeen.add(key);
turnThinkingBuffer.push(text);
};
try {
// Use SSE fetch — stream steps live as they arrive
currentAbortController = new AbortController();
let partialContent = '';
const res = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
}),
signal: currentAbortController.signal,
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let event;
try { event = JSON.parse(line.slice(6)); } catch { continue; }
switch (event.type) {
case 'agent_mode':
lastAgentMode = event.mode || '-';
lastTurnKind = event.turnKind || lastTurnKind;
if (event.mode === 'execute') sawExecuteModeThisTurn = true;
addProcessEntry(
'info',
`Agent mode: ${event.mode || 'unknown'}${event.turnKind ? ` (${event.turnKind})` : ''}${event.switched_from ? ` | switched_from=${event.switched_from}` : ''}${event.route_target ? ` | route_target=${event.route_target}` : ''}${event.trigger ? ` | trigger=${event.trigger}` : ''}`
);
if (event.mode === 'execute') renderLiveStepsPanel();
loadAgentSessionContext();
break;
case 'session_mode_locked':
addProcessEntry('info', `Session mode locked: ${event.mode || 'unknown'}`);
break;
case 'decomposed':
addProcessEntry('split', `Split into ${event.questions.length} sub-questions: ${event.questions.map((q,i) => `Q${i+1}: ${q.slice(0,40)}`).join(' | ')}`);
break;
case 'thinking':
if (event.thinking && String(event.thinking).trim()) {
const thinkingText = String(event.thinking).trim();
collectTurnThinking(thinkingText);
addProcessEntry('think', thinkingText);
}
break;
case 'token':
case 'text_delta': {
const token = event.text || event.content || event.delta || '';
if (token) {
partialContent += token;
if (window._voiceCallOnToken) window._voiceCallOnToken(partialContent);
}
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
// partialContent just got replaced wholesale (not appended to), so
// any voice-call spokenUpTo offset computed against the old string
// is now meaningless — reset before feeding the new text through.
if (window._voiceCallResetSpoken) window._voiceCallResetSpoken();
if (window._voiceCallOnToken) window._voiceCallOnToken(partialContent);
}
break;
}
case 'memory_suggest':
// event.suggestion: { fact, reference, source_tool, source_output, actor }
const s = event.suggestion || {};
addProcessEntry('memory', s.fact || '(memory suggestion)', s);
addProcessEntry('info', 'A memory suggestion was created; approve to persist.');
break;
case 'memory_saved':
addProcessEntry(event.ok ? 'result' : 'warn', event.ok
? `Memory updated${event.key ? ` (${event.key})` : ''}.`
: 'Memory update failed.');
break;
case 'web_search_snippets':
// event: { query, snippets }
const q = event.query || '(search)';
addProcessEntry('info', `Search results: ${q}`, event.snippets || []);
if (event.diagnostics && Array.isArray(event.diagnostics.attempted)) {
const providers = event.diagnostics.attempted.map(a => {
const p = String(a.provider || '').toLowerCase();
const status = String(a.status || '').toLowerCase();
if (status === 'success') {
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
return `${p}=success${count}`;
}
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
}).join(' | ');
addProcessEntry('info', `Providers: ${providers}`);
}
break;
case 'image':
if (event.url) {
pushProgressLine(`Image: ${event.alt || event.url}`);
const imgAlt = String(event.alt || '').replace(/"/g, '"');
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, ''').replace(/✖ ${imgErrAlt}를 불러올 수 없습니다.')`;
const imgHtml = ` `;
// 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 'usage': {
const pt = Number(event.promptTokens || 0);
if (pt > 0) {
const idx = chatSessions.findIndex(s => s.id === activeChatSessionId);
if (idx !== -1) { chatSessions[idx].contextTokens = pt; saveChatSessions(); renderSessionsList(); }
}
break;
}
// Sent before the model call, so it survives a turn that gets aborted before
// a real 'usage' event (with prompt_eval_count) arrives — lets the fallback
// estimate in renderSessionsList include tool-schema overhead instead of
// silently ignoring it.
case 'tool_overhead': {
const overhead = Number(event.tokens || 0);
if (overhead > 0) {
const idx = chatSessions.findIndex(s => s.id === activeChatSessionId);
if (idx !== -1) { chatSessions[idx].toolOverheadTokens = overhead; saveChatSessions(); }
}
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 (window._voiceCallOnTurnDone) window._voiceCallOnTurnDone(finalReply || partialContent);
if (finalReply) {
// Append file cards for any attachments sent via the 'files' SSE event
if (turnFileLinks.length > 0) {
const seen = new Set();
const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; });
// Only append links not already present as markdown in the reply
const toAppend = newLinks.filter(l => !finalReply.includes(l.url || ''));
if (toAppend.length > 0) {
finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n');
}
}
const finalStep = allSteps.find(s => s.finalAnswer);
const mergedThinking = turnThinkingBuffer.join('\n\n').trim();
const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn;
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
chatHistory.push({
role: 'ai',
content: finalReply,
artifacts: finalArtifacts,
steps: allSteps,
mode: useAgentMode ? 'agentic' : 'chat',
thinking: shouldAttachThinkingPanel ? (mergedThinking || finalStep?.thinking) : '',
processEntries: turnEntries
});
} else {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
chatHistory.push({ role: 'ai', content: 'No response received.', processEntries: turnEntries });
}
persistActiveChat();
// PPTX: detect pptx-outline block in final reply
if (finalReply) {
const outlineMatch = finalReply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
if (outlineMatch) {
try { pptxSetOutline(JSON.parse(outlineMatch[1])); } catch {}
}
}
// Auto-open code blocks from final reply in the editor
if (finalReply && typeof codeAiRenderMarkdown === 'function') {
const codeBlockRe = /```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```/g;
let cbm;
while ((cbm = codeBlockRe.exec(finalReply)) !== null) {
const lang = cbm[1] || 'text';
const code = cbm[2];
if (codeIsOpenable(code, lang)) {
codeAiOpenInEditor(code, lang);
}
}
}
} catch (err) {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
if (err.name === 'AbortError') {
addProcessEntry('warn', 'Generation stopped by user.');
const content = partialContent ||
(allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]');
chatHistory.push({ role: 'ai', content, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', processEntries: turnEntries });
} else {
lastHeartbeat = { ...lastHeartbeat, state: 'stalled', level: 'hard', message: String(err.message || 'connection_error'), current_step: 'error' };
updateHeartbeatUI();
addProcessEntry('error', err.message);
chatHistory.push({ role: 'ai', content: `Connection error: ${err.message}`, processEntries: turnEntries });
}
persistActiveChat();
}
isThinking = false;
if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush();
if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }
streamingSessionId = null; // release session lock
currentAbortController = null;
setButtonState(false);
currentPreflightStatus = '';
currentProgressLines = [];
lastHeartbeat = { ...lastHeartbeat, state: 'idle', level: '', message: '', current_step: 'done' };
updateHeartbeatUI();
currentTurnStartIndex = -1;
sendBtn.disabled = false;
renderChatMessages();
updateQueuedPromptUI();
const shouldPauseQueue = isFailedTurnReply(finalReply || (chatHistory[chatHistory.length - 1]?.content || ''));
if (queuedPrompts.length > 0 && shouldPauseQueue) {
addProcessEntry('warn', 'Queue paused because the previous turn failed/blocked. Press Send to resume queued prompts.');
} else if (queuedPrompts.length > 0) {
const next = queuedPrompts.shift();
updateQueuedPromptUI();
addProcessEntry('info', `Auto-running queued prompt${queuedPrompts.length ? ` (${queuedPrompts.length} remaining)` : ''}.`);
setTimeout(() => { sendChat(next); }, 0);
}
}
// ---- Agent Execution Tracking ----
function spawnAgentExecution(agentName, taskPrompt, isSubagent = false) {
const agentId = (isSubagent ? 'sub_' : 'main_') + (++agentIdCounter);
const agentData = {
id: agentId,
name: agentName,
status: AGENT_STATUS.ACTIVE,
task: taskPrompt,
logs: [],
isSubagent: isSubagent,
startTime: new Date(),
};
agentExecutionMap.set(agentId, agentData);
renderAgentExecutionPanel();
return agentId;
}
function addAgentLog(agentId, type, content) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
const ts = new Date().toLocaleTimeString();
agentData.logs.push({ ts, type, content });
// Keep only last 20 logs per agent
if (agentData.logs.length > 20) agentData.logs.shift();
renderAgentExecutionPanel();
}
function completeAgentExecution(agentId) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
agentData.status = AGENT_STATUS.COMPLETED;
renderAgentExecutionPanel();
}
function pauseAgentExecution(agentId, reason) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
agentData.status = AGENT_STATUS.PAUSED;
if (reason) addAgentLog(agentId, 'error', reason);
renderAgentExecutionPanel();
}
function renderAgentExecutionPanel() {
const panel = document.getElementById('agent-execution-panel');
if (!panel) return;
if (agentExecutionMap.size === 0) {
panel.innerHTML = '작업 실행 대기 중...
';
return;
}
const agents = Array.from(agentExecutionMap.values()).sort((a, b) => a.startTime - b.startTime);
panel.innerHTML = agents.map((agent) => {
const statusClass = agent.status === AGENT_STATUS.ACTIVE ? 'active' :
agent.status === AGENT_STATUS.COMPLETED ? 'completed' : 'paused';
const arrow = agent.expanded ? '▼' : '▶';
const logsHtml = agent.logs.length > 0
? agent.logs.map(log => `${escHtml(log.ts)} [${escHtml(log.type.toUpperCase())}] ${escHtml(log.content)}
`).join('')
: 'No logs yet
';
return `
${escHtml(agent.task.substring(0, 70))}${agent.task.length > 70 ? '...' : ''}
${logsHtml}
`;
}).join('');
}
function toggleAgentPanel(agentId) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
agentData.expanded = !agentData.expanded;
renderAgentExecutionPanel();
}
function openProcessLogFile() {
// Call server endpoint to open the process log file
if (activeTaskId) {
fetch(`/api/open-file?task=${encodeURIComponent(activeTaskId)}&type=process`, {
headers: authHeaders()
})
.catch(err => console.error('Unable to open process log:', err));
}
}
function clearChat() {
chatHistory = [];
persistActiveChat();
renderChatMessages();
}
function toggleExportMenu(e) {
if (e) e.stopPropagation();
var menu = document.getElementById('export-menu');
if (!menu) return;
var opening = menu.style.display === 'none' || !menu.style.display;
menu.style.display = opening ? 'block' : 'none';
if (opening) {
setTimeout(function() {
document.addEventListener('click', function closeExport() {
menu.style.display = 'none';
document.removeEventListener('click', closeExport);
});
}, 0);
}
}
function exportChat(format) {
var menu = document.getElementById('export-menu');
if (menu) menu.style.display = 'none';
if (!chatHistory || !chatHistory.length) {
alert('내보낼 채팅 내용이 없습니다.');
return;
}
var sess = chatSessions.find(function(s) { return s.id === activeChatSessionId; });
var title = (sess && sess.title) ? sess.title : '채팅';
var dateStr = new Date().toLocaleString('ko-KR');
var safeName = title.replace(/[^\w가-힣\s-]/g, '').trim().replace(/\s+/g, '_').slice(0, 40) || 'chat';
var dateSuffix = new Date().toISOString().slice(0, 10);
var filename = safeName + '_' + dateSuffix;
var content, mime, ext;
if (format === 'json') {
content = JSON.stringify({
title: title,
exportedAt: new Date().toISOString(),
sessionId: activeChatSessionId,
messages: chatHistory.map(function(m) {
return { role: m.role, content: (m.display || m.content || '').trim() };
})
}, null, 2);
mime = 'application/json';
ext = 'json';
} else if (format === 'md') {
var lines = ['# ' + title, '', '> 내보낸 시간: ' + dateStr, ''];
chatHistory.forEach(function(m) {
var label = m.role === 'user' ? '## 사용자' : '## 어시스턴트';
var text = (m.display || m.content || '').trim();
lines.push(label, '', text, '', '---', '');
});
content = lines.join('\n');
mime = 'text/markdown';
ext = 'md';
} else {
var lines = [title, '내보낸 시간: ' + dateStr, '='.repeat(50), ''];
chatHistory.forEach(function(m) {
var label = m.role === 'user' ? '[사용자]' : '[AI]';
var text = (m.display || m.content || '').trim()
.replace(/!\[[^\]]*\]\([^)]*\)/g, '[이미지]')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[*_`#>]/g, '');
lines.push(label, text, '');
});
content = lines.join('\n');
mime = 'text/plain';
ext = 'txt';
}
var blob = new Blob([content], { type: mime + '; charset=utf-8' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename + '.' + ext;
document.body.appendChild(a);
a.click();
setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
// Enter to send, Shift+Enter for newline
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChat();
}
});
// Auto-resize textarea
document.getElementById('chat-input').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 140) + 'px';
});
// ---- WebSocket ----
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const token = getAuthToken();
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws${qs}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'jobs_update') {
allJobs = msg.jobs;
updateStats([]);
if (selectedJobId) refreshJobDetail(selectedJobId);
}
if (msg.type === 'job_created') {
log(`Job created: ${msg.jobId}`, 'info');
}
if (msg.type === 'boot_greeting') {
// Show boot greeting in current session regardless of existing history
const bootSess = chatSessions.find(s => s.id === activeChatSessionId);
if (bootSess) {
if (!bootSess.history) bootSess.history = [];
bootSess.history.push({ role: 'assistant', content: msg.text });
bootSess.updatedAt = Date.now();
saveChatSessions();
syncActiveChat();
renderChatMessages();
}
}
// -- Cron/Heartbeat task events --
if (msg.type === 'tasks_update') {
hbJobs = msg.jobs || [];
if (msg.config) hbConfig = msg.config;
if (currentMode === 'heartbeat') renderHeartbeatPage();
}
if (msg.type === 'task_running') {
const job = hbJobs.find(j => j.id === msg.jobId);
if (job) job.status = 'running';
if (currentMode === 'heartbeat') renderHeartbeatPage();
addProcessEntry('info', `Task running: ${msg.jobName}`);
}
if (msg.type === 'task_done') {
hbJobs = msg.jobs || hbJobs;
if (msg.config) hbConfig = msg.config;
if (currentMode === 'heartbeat') renderHeartbeatPage();
if (msg.automatedSession && !msg.isOk) {
// Add the automated session to chatSessions so it appears in the sidebar
const as = msg.automatedSession;
const existingIdx = chatSessions.findIndex(s => s.id === as.id);
const newSession = {
id: as.id, title: as.title,
history: as.history.map(m => ({ role: m.role === 'ai' ? 'assistant' : m.role, content: m.content })),
processLog: [], createdAt: as.createdAt, updatedAt: Date.now(),
automated: true, jobName: as.jobName, unread: true,
};
if (existingIdx === -1) chatSessions.push(newSession);
else chatSessions[existingIdx] = { ...chatSessions[existingIdx], ...newSession };
saveChatSessions();
renderSessionsList();
bgtToast('\uD83D\uDD50 Scheduled task done', `"${msg.jobName}" — results in sidebar`);
addProcessEntry('info', `Task "${msg.jobName}" completed — check sidebar for results`);
} else if (msg.isOk) {
addProcessEntry('info', `Task "${msg.jobName}" — OK (nothing to report)`);
}
}
// Cron job spawned a background task — switch to tasks view if currently watching
if (msg.type === 'cron_task_spawned') {
bgtToast('\uD83D\uDD50 Scheduled job running', `"${msg.jobName}" started as background task`);
if (currentMode === 'bgtasks') refreshBgTasks();
}
if (msg.type === 'task_sse') {
// Live progress from a running task — could update the NOW card
if (currentMode === 'heartbeat' && msg.event === 'tool_result') {
const card = document.querySelector(`[data-job-id="${msg.jobId}"] .hb-card-meta`);
if (card) card.innerHTML = `Working: ${escHtml((msg.data?.action || '').slice(0,40))}... `;
}
}
// -- Background Task (Kanban) events --
if (msg.type === 'task_running' || msg.type === 'task_complete' || msg.type === 'task_paused' ||
msg.type === 'task_failed' || msg.type === 'task_needs_assistance' || msg.type === 'task_step_done' || msg.type === 'task_tool_call' ||
msg.type === 'task_heartbeat_tick' || msg.type === 'task_heartbeat_resumed') {
// Live-refresh the kanban board if visible
if (currentMode === 'bgtasks') refreshBgTasks();
// If panel is open for this task, refresh it
if (bgtOpenTaskId && (msg.taskId === bgtOpenTaskId)) {
bgtRefreshOpenPanel();
}
// Toast notification for completions and errors
if (msg.type === 'task_complete') {
bgtToast(`\u2713 Task complete`, msg.summary ? msg.summary.slice(0,120) : '');
// If this task has a cron session ID (starts with cron_), create/update a sidebar session
// so the results are immediately visible without a page refresh.
if (msg.taskId) {
(async () => {
try {
const data = await fetch(`/api/bg-tasks/${msg.taskId}`, {
headers: authHeaders()
}).then(r => r.json());
const task = data?.task || data;
if (task && task.sessionId && task.sessionId.startsWith('cron_')) {
const journal = task.journal || [];
const summaryEntry = journal.filter(e => e.type === 'summary' || e.type === 'complete').pop();
const summaryText = msg.summary || (summaryEntry?.content) || '(Task complete — open Tasks panel for details)';
const titleText = `\uD83D\uDD50 ${task.title || 'Scheduled Task'} \u2014 ${new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`;
const sessionId = `auto_${task.id}_${Date.now()}`;
const existingIdx = chatSessions.findIndex(s => s.id === task.sessionId || (s.jobName && s.automated && s.title === titleText));
const newSession = {
id: sessionId, title: titleText,
history: [
{ role: 'user', content: `[Scheduled Task: ${task.title}]\n\n${task.prompt || ''}` },
{ role: 'assistant', content: summaryText },
],
processLog: [], createdAt: Date.now(), updatedAt: Date.now(),
automated: true, jobName: task.title, unread: true,
};
if (existingIdx === -1) chatSessions.push(newSession);
saveChatSessions();
renderSessionsList();
}
} catch {}
})();
}
} else if (msg.type === 'task_failed') {
bgtToast(`\u2715 Task failed`, msg.error ? msg.error.slice(0,120) : '');
} else if (msg.type === 'task_needs_assistance') {
bgtToast(`Task paused`, msg.reason ? String(msg.reason).slice(0, 120) : 'Needs assistance');
}
}
// Panel-only refresh (frequent tool_call updates — only refresh open panel, not full kanban)
if (msg.type === 'task_panel_update') {
if (bgtOpenTaskId && msg.taskId === bgtOpenTaskId) {
bgtRefreshOpenPanel();
}
}
// Notification from task runner (status push to channel)
if (msg.type === 'task_notification' && msg.sessionId) {
// Try to add a status message to the originating chat session
const sess = chatSessions.find(s => s.id === msg.sessionId);
if (sess) {
sess.history = sess.history || [];
sess.history.push({ role: 'assistant', content: msg.message || '' });
saveChatSessions();
if (currentSessionId === msg.sessionId) renderMessages();
}
}
// ---- Agent Execution Events ----
if (msg.type === 'agent_spawned') {
const agentId = spawnAgentExecution(msg.name || 'Subagent', msg.task || '', msg.isSubagent);
// Store mapping for WebSocket message routing
if (msg.serverAgentId) {
if (!window.agentIdMap) window.agentIdMap = {};
window.agentIdMap[msg.serverAgentId] = agentId;
}
}
if (msg.type === 'agent_log') {
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
if (agentId) addAgentLog(agentId, msg.logType || 'info', msg.content);
}
if (msg.type === 'agent_completed') {
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
if (agentId) completeAgentExecution(agentId);
}
if (msg.type === 'agent_paused') {
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
if (agentId) pauseAgentExecution(agentId, msg.reason);
}
};
ws.onclose = () => setTimeout(connectWS, 2000);
ws.onerror = () => ws.close();
}
// ---- API ----
async function api(path, opts = {}) {
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
const r = await fetch(API + path, { headers, ...restOpts });
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
const ct = String(r.headers.get('content-type') || '').toLowerCase();
const bodyText = await r.text();
if (!ct.includes('application/json')) {
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
}
let data = null;
try {
data = bodyText ? JSON.parse(bodyText) : null;
} catch {
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`);
}
if (!r.ok) {
const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`;
throw new Error(msg);
}
return data;
}
const LAST_USER_KEY = 'cherryclaw_last_user';
function clearLocalUserState(reason) {
try {
localStorage.removeItem(chatSessionsKey());
localStorage.removeItem(agentSessionKey());
} catch {}
try {
chatSessions = [];
activeChatSessionId = null;
} catch {}
setAgentSessionId(generateSessionId());
// Rebuild a fresh "New chat" + re-render so the UI does not briefly
// display the previous user's session list before the wipe takes effect.
try { if (typeof loadChatSessions === 'function') loadChatSessions(); } catch {}
try { if (typeof renderSessionsList === 'function') renderSessionsList(); } catch {}
try { if (typeof renderMessages === 'function') renderMessages(); } catch {}
console.log(`[auth] cleared local session state (${reason})`);
}
async function checkAuth() {
try {
const res = await fetch('/api/auth/status', { headers: authHeaders() });
const data = await res.json();
if (data.enabled && !data.authenticated && !data.hasPassword) {
window.location.href = '/login.html';
return;
}
if (data.enabled && !data.authenticated) {
window.location.href = '/login.html';
return;
}
if (data.authenticated && data.username) {
currentUser = { username: data.username, role: data.role };
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
// This also migrates any data from the old non-namespaced key on first load after server restart.
if (typeof loadChatSessions === 'function') loadChatSessions();
_fetchAppCtxMax();
// Multi-user safety: if a different user was logged in on this browser
// before, wipe the cached session list & active sessionId so the
// incoming user does not reuse the previous user's chat IDs.
const lastUser = (localStorage.getItem(LAST_USER_KEY) || '').trim();
if (lastUser && lastUser !== data.username) {
clearLocalUserState(`user changed: ${lastUser} -> ${data.username}`);
}
try { localStorage.setItem(LAST_USER_KEY, data.username); } catch {}
// Show user pill with username
const pill = document.getElementById('user-info-pill');
const nameEl = document.getElementById('user-info-name');
if (pill) pill.style.display = 'flex';
if (nameEl) nameEl.textContent = data.username;
const mobileLogout = document.getElementById('mob-tab-logout');
if (mobileLogout) mobileLogout.style.display = 'flex';
}
} catch (e) {
console.error('Auth check failed:', e);
}
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() });
} catch (e) { /* ignore network errors */ }
// Wipe cached chat list / sessionId so a different user logging in next
// does not inherit any of this user's session IDs.
clearLocalUserState('logout');
try { localStorage.removeItem(LAST_USER_KEY); } catch {}
setAuthToken('');
window.location.href = '/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(); loadBannedIpsTab(); }
} else {
panel.style.display = 'none';
}
}
});
}
// --- Credentials Tab ---------------------------------------------------------
async function loadCredentialsTab() {
await Promise.all([loadCredFields(), loadCredVaultStatus(), loadCredVaultLog()]);
}
async function loadCredFields() {
try {
const s = await api('/api/settings/search');
// Server returns '••••••••' if key is set, '' if not
const setField = (id, val) => {
const el = document.getElementById(id);
if (!el) return;
el.value = val || '';
el.placeholder = val ? '•••••••• (key stored — enter new value to replace)' : el.getAttribute('data-placeholder') || '';
};
setField('cred-tavily-key', s.tavily_api_key);
setField('cred-google-key', s.google_api_key);
setField('cred-brave-key', s.brave_api_key);
const cxEl = document.getElementById('cred-google-cx');
if (cxEl) cxEl.value = s.google_cx || '';
} catch(e) {
console.warn('loadCredFields:', e);
}
// OpenWeather key is stored separately
try {
const ow = await api('/api/settings/openweather');
const owEl = document.getElementById('cred-ow-key');
if (owEl) {
owEl.value = ow.api_key || '';
owEl.placeholder = ow.api_key ? '•••••••• (key stored — enter new value to replace)' : '';
}
} catch(e) {
console.warn('loadCredFields openweather:', e);
}
}
async function loadCredVaultStatus() {
const el = document.getElementById('cred-vault-status');
if (!el) return;
try {
const data = await api('/api/credentials/status');
const keys = data.keys || [];
if (!keys.length) {
el.innerHTML = '⚠ No credentials stored yet. ';
return;
}
el.innerHTML = keys.map(k => {
const label = {
'search.tavily_api_key': 'Tavily API Key',
'search.google_api_key': 'Google API Key',
'search.brave_api_key': 'Brave API Key',
'llm.openai.api_key': 'OpenAI API Key',
'hooks.token': 'Webhook Token',
'channels.telegram.botToken': 'Telegram Token',
'channels.discord.botToken': 'Discord Token',
'openweather.api_key': 'OpenWeather API Key',
}[k] || k;
return `
✓
${label}
${k}
`;
}).join('');
} catch(e) {
el.innerHTML = `Could not load vault status: ${e.message} `;
}
}
async function loadCredVaultLog() {
const el = document.getElementById('cred-vault-log');
if (!el) return;
try {
const data = await api('/api/credentials/audit');
const lines = (data.lines || []).slice(-18).reverse();
if (!lines.length) { el.textContent = '감사 항목 없음.'; return; }
el.innerHTML = lines.map(l => {
const safe = l.replace(/&/g,'&').replace(//g,'>');
const color = l.includes('SET') ? 'var(--ok)' : l.includes('GET') ? 'var(--brand)' : l.includes('DEL') ? 'var(--err)' : 'var(--muted)';
return `${safe}
`;
}).join('');
} catch(e) {
el.textContent = '감사 로그를 불러올 수 없습니다.';
}
}
function toggleCredVis(inputId, btn) {
const el = document.getElementById(inputId);
if (!el) return;
if (el.type === 'password') {
el.type = 'text';
btn.textContent = '🙈';
// Auto-rehide after 8s
setTimeout(() => { el.type = 'password'; btn.textContent = '👁'; }, 8000);
} else {
el.type = 'password';
btn.textContent = '👁';
}
}
// --- Provider-aware Model Settings ------------------------------------------
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic'];
function onProviderChange() {
const provider = document.getElementById('settings-llm-provider').value;
// Show only the matching fields panel
PROVIDER_IDS.forEach(id => {
const el = document.getElementById('prov-fields-' + id);
if (el) el.style.display = id === provider ? 'block' : 'none';
});
if (provider === 'openai') {
refreshOpenAIModels(true).catch(() => {});
} else if (['ollama', 'lm_studio', 'llama_cpp'].includes(provider)) {
refreshProviderModels().catch(() => {});
}
syncOrchPreemptControls();
}
async function loadModelSettings() {
try {
const data = await api('/api/settings/provider');
const llm = data?.llm || { provider: 'ollama', providers: {} };
const prov = llm.provider || 'ollama';
const provSel = document.getElementById('settings-llm-provider');
if (provSel) provSel.value = prov;
// Only toggle visibility, skip model refresh here to avoid double-loading
PROVIDER_IDS.forEach(id => {
const el = document.getElementById('prov-fields-' + id);
if (el) el.style.display = id === prov ? 'block' : 'none';
});
const pc = llm.providers || {};
// Populate each provider's fields from saved config
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
v('settings-ollama-endpoint', pc.ollama?.endpoint);
v('settings-llamacpp-endpoint', pc.llama_cpp?.endpoint);
v('settings-llamacpp-model', pc.llama_cpp?.model);
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
v('settings-lmstudio-model', pc.lm_studio?.model);
v('settings-openai-key', pc.openai?.api_key);
v('settings-anthropic-key', pc.anthropic?.api_key);
if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; }
// Set primary model from saved config before refreshing the list
// Fallback to data.models_primary (models.primary) when provider's model field is empty
const savedPrimaryModel = pc[prov]?.model || data?.models_primary || '';
if (savedPrimaryModel) {
const sel = document.getElementById('settings-primary-model');
if (sel) sel.value = savedPrimaryModel;
}
if (pc.openai?.model) { const s = document.getElementById('settings-openai-model'); if (s) s.value = pc.openai.model; }
if (pc.openai_codex?.model) { const s = document.getElementById('settings-codex-model'); if (s) s.value = pc.openai_codex.model; }
// Check Codex OAuth status
await refreshCodexStatus();
// Refresh OpenAI model dropdown from current key when applicable
if (prov === 'openai') await refreshOpenAIModels(true);
// Auto-load model list for list-capable providers (preserves saved selection)
if (['ollama', 'lm_studio', 'llama_cpp'].includes(prov)) await refreshProviderModels(savedPrimaryModel);
_modelSettingsLoaded = true;
} catch (e) {
console.warn('loadModelSettings error:', e);
}
}
async function refreshProviderModels(forcedValue) {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const msg = document.getElementById('provider-status-msg');
if (msg) msg.textContent = '모델 로딩 중…';
try {
const llm = buildProviderPayload();
if (llm.provider !== provider) llm.provider = provider;
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
const selects = ['settings-primary-model'];
selects.forEach((selId, i) => {
const sel = document.getElementById(selId);
if (!sel) return;
if (i === 0) {
// forcedValue: saved model passed from loadModelSettings (DOM value not yet set when options are empty)
const prev = forcedValue || String(sel.value || '').trim();
sel.innerHTML = models.map(m => `${escHtml(m)} `).join('');
if (models.length) sel.value = (prev && models.includes(prev)) ? prev : models[0];
}
});
if (msg) msg.textContent = models.length ? `${models.length}개 모델 발견.` : '모델 없음 — 서버가 실행 중인가요?';
} catch (e) {
if (msg) msg.textContent = '모델 가져오기 실패.';
}
}
async function testProviderConnection() {
const provider = document.getElementById('settings-llm-provider').value;
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status', anthropic: 'provider-status-msg-anthropic' };
const msgId = statusIds[provider] || 'provider-status-msg';
const msg = document.getElementById(msgId);
if (msg) msg.textContent = '테스트 중…';
if (provider === 'openai') {
const ok = await refreshOpenAIModels(false);
if (msg) msg.textContent = ok ? '✓ 연결됨' : (msg.textContent || '✗ 연결 실패');
return;
}
try {
const llm = buildProviderPayload();
if (llm.provider !== provider) llm.provider = provider;
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
if (msg) msg.textContent = data?.success ? '✓ 연결됨' : '✗ ' + (data?.error || '연결 실패');
} catch (e) {
if (msg) msg.textContent = '✗ ' + e.message;
}
}
function updateOpenAIModelDropdown(models) {
const sel = document.getElementById('settings-openai-model');
if (!sel) return;
const current = String(sel.value || '').trim();
const unique = Array.from(new Set((models || []).filter(Boolean).map(m => String(m).trim()).filter(Boolean)));
if (!unique.length) return;
if (current && !unique.includes(current)) unique.unshift(current);
sel.innerHTML = unique.slice(0, 500).map(m => `${escHtml(m)} `).join('');
if (current) sel.value = current;
}
async function refreshOpenAIModels(silent = false) {
const statusEl = document.getElementById('provider-status-msg-openai');
const apiKey = document.getElementById('settings-openai-key')?.value?.trim() || '';
if (!apiKey) {
if (!silent && statusEl) statusEl.textContent = 'API 키를 먼저 입력하세요.';
return false;
}
if (!silent && statusEl) statusEl.textContent = '모델 목록 가져오는 중…';
try {
const data = await api('/api/openai/models', { method: 'POST', body: JSON.stringify({ api_key: apiKey }) });
if (!data?.success) {
if (statusEl) statusEl.textContent = '✗ ' + (data?.error || '모델 가져오기 실패');
return false;
}
const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
updateOpenAIModelDropdown(models);
if (statusEl) statusEl.textContent = models.length ? `✓ ${models.length}개 모델 사용 가능` : '✓ 연결됨';
return true;
} catch (e) {
if (statusEl) statusEl.textContent = '✗ ' + e.message;
return false;
}
}
// Build the llm config object from current UI state
function buildProviderPayload() {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const providers = {};
providers.ollama = { endpoint: document.getElementById('settings-ollama-endpoint')?.value || 'http://localhost:11434', model: document.getElementById('settings-primary-model')?.value || '' };
providers.llama_cpp = { endpoint: document.getElementById('settings-llamacpp-endpoint')?.value || 'http://localhost:8080', model: document.getElementById('settings-llamacpp-model')?.value || '' };
providers.lm_studio = { endpoint: document.getElementById('settings-lmstudio-endpoint')?.value || 'http://localhost:1234', model: document.getElementById('settings-lmstudio-model')?.value || '' };
providers.openai = { api_key: document.getElementById('settings-openai-key')?.value || '', model: document.getElementById('settings-openai-model')?.value || 'gpt-4o' };
providers.openai_codex = { model: document.getElementById('settings-codex-model')?.value || 'gpt-5.3-codex' };
providers.anthropic = { api_key: document.getElementById('settings-anthropic-key')?.value || '', model: document.getElementById('settings-anthropic-model')?.value || 'claude-sonnet-4-6' };
return { provider, providers };
}
// --- Codex OAuth UI ------------------------------------------------
async function refreshCodexStatus() {
try {
const data = await api('/api/auth/openai/status');
const disc = document.getElementById('codex-disconnected-state');
const conn = document.getElementById('codex-connected-state');
const acct = document.getElementById('codex-account-id');
if (data?.connected) {
if (disc) disc.style.display = 'none';
if (conn) conn.style.display = 'block';
if (acct) acct.textContent = data.account_id ? `계정: ${data.account_id}` : '계정 연결됨';
} else {
if (disc) disc.style.display = 'block';
if (conn) conn.style.display = 'none';
}
} catch {}
}
async function startCodexOAuth() {
const statusEl = document.getElementById('codex-oauth-status');
if (statusEl) statusEl.textContent = '브라우저 열기 중…';
try {
const data = await api('/api/auth/openai/start', { method: 'POST', body: '{}' });
if (data?.needsManualPaste) {
if (statusEl) statusEl.innerHTML = 'Browser did not open. Click here to authorize , then paste the redirect URL below.Submit ';
} else if (data?.success) {
if (statusEl) statusEl.textContent = '';
await refreshCodexStatus();
} else {
if (statusEl) statusEl.textContent = '? ' + (data?.error || 'OAuth failed');
}
} catch (e) {
if (statusEl) statusEl.textContent = '✗ ' + e.message;
}
}
async function submitManualCodexUrl() {
const url = document.getElementById('codex-manual-url')?.value?.trim();
if (!url) return;
const statusEl = document.getElementById('codex-oauth-status');
if (statusEl) statusEl.textContent = '토큰 교환 중…';
try {
const data = await api('/api/auth/openai/manual', { method: 'POST', body: JSON.stringify({ url }) });
if (data?.success) {
if (statusEl) statusEl.textContent = '';
await refreshCodexStatus();
} else {
if (statusEl) statusEl.textContent = '? ' + (data?.error || 'Failed');
}
} catch (e) {
if (statusEl) statusEl.textContent = '✗ ' + e.message;
}
}
async function disconnectCodex() {
await api('/api/auth/openai/disconnect', { method: 'POST', body: '{}' });
await refreshCodexStatus();
}
// Legacy alias so any old references still work
async function refreshOllamaModels() { await refreshProviderModels(); }
async function loadAgentSessionContext() {
try {
if (!agentSessionId) setAgentSessionId(activeChatSessionId);
const sid = encodeURIComponent(agentSessionId);
const data = await api(`/api/agent/session/${sid}`);
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
set('r-session-mode', data.mode_lock || '-');
set('r-agent-state', `${data.mode || '-'} / ${lastTurnKind !== '-' ? lastTurnKind : 'n/a'}`);
set('r-overview-objective', (data.overview_objective || '(none)').slice(0, 56));
set('r-active-objective', (data.active_objective || '(none)').slice(0, 56));
const tc = data.task_counts || {};
const tr = data.turn_counts || {};
const ec = data.execution_counts || {};
const useExecutionCounts = Number(ec.total || 0) > 0 || data.current_turn_execution;
if (useExecutionCounts) {
const doneExec = Number(ec.done || 0) + Number(ec.repaired || 0);
set('r-task-counts', `${doneExec}/${ec.total || 0} completed`);
set('r-turn-counts', `${ec.running || 0} running • ${ec.failed || 0} failed`);
} else {
set('r-task-counts', `${tc.done || 0}/${tc.total || 0} done`);
set('r-turn-counts', `${tr.completed || 0} done • ${tr.open || 0} open`);
}
const total = useExecutionCounts ? Number(ec.total || 0) : Number(tc.total || 0);
const done = useExecutionCounts ? (Number(ec.done || 0) + Number(ec.repaired || 0)) : Number(tc.done || 0);
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0;
const bar = document.getElementById('r-task-progress-bar');
if (bar) bar.style.width = `${pct}%`;
const execPanel = document.getElementById('r-turn-execution');
if (execPanel) {
const cur = data.current_turn_execution;
if (!cur || typeof cur !== 'object') {
execPanel.innerHTML = '활성 턴 실행 없음.
';
} else {
const objective = escHtml(String(cur.objective_raw || '(no objective)').slice(0, 220));
const status = escHtml(String(cur.status || 'planned'));
const mode = escHtml(String(cur.mode || 'unknown'));
const turnKind = escHtml(String(cur.turn_kind || 'unknown'));
const steps = Array.isArray(cur.steps) ? cur.steps : [];
const verification = cur.verification && typeof cur.verification === 'object' ? cur.verification : null;
const trace = cur.decision_trace && typeof cur.decision_trace === 'object' ? cur.decision_trace : null;
const traceEvents = Array.isArray(trace?.events) ? trace.events : [];
const stepRows = steps.map((s, idx) => {
const stepTitle = escHtml(String(s.title || s.type || `Step ${idx + 1}`));
const stepStatus = escHtml(String(s.status || 'pending'));
const outputs = s.outputs && typeof s.outputs === 'object'
? `${escHtml(JSON.stringify(s.outputs).slice(0, 160))}
`
: '';
return `
${idx + 1}. ${stepTitle}
${stepStatus.replace('_', ' ')}
${outputs}
`;
}).join('');
const verifyHtml = verification
? `verify: ${escHtml(String(verification.status || 'pass'))} ${Array.isArray(verification.repairs_applied) && verification.repairs_applied.length ? ` • repairs: ${verification.repairs_applied.length}` : ''}${Array.isArray(verification.errors) && verification.errors.length ? ` • errors: ${verification.errors.length}` : ''}
`
: 'verify: (not run)
';
const traceHtml = traceEvents.length
? `
Decision Trace
${traceEvents.length}
${traceEvents.slice(-10).map((ev) => {
const stage = escHtml(String(ev?.stage || 'trace'));
const msg = escHtml(String(ev?.message || '').slice(0, 180));
return `
[${stage}] ${msg}
`;
}).join('')}
`
: '';
execPanel.innerHTML = `
Current Turn
${status.replace('_', ' ')}
${objective}
mode: ${mode} • kind: ${turnKind}
${verifyHtml}
${stepRows || 'No execution steps.
'}
${traceHtml}
`;
}
}
const taskList = document.getElementById('r-task-list');
if (taskList) {
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
if (!tasks.length) {
taskList.innerHTML = '작업 없음.
';
} else {
taskList.innerHTML = tasks.slice(0, 20).map(t => {
const title = escHtml(String(t.title || '').slice(0, 120));
const status = escHtml(String(t.status || 'pending'));
const tool = escHtml(String(t.tool || 'tool'));
return `
${title}
${status.replace('_', ' ')}
tool: ${tool}
`;
}).join('');
}
}
const rt = document.getElementById('r-recent-turns');
if (rt) {
const recentExec = Array.isArray(data.recent_turn_executions) ? data.recent_turn_executions : [];
if (recentExec.length) {
rt.innerHTML = recentExec.slice(0, 10).map((e, idx) => {
const status = escHtml(String(e.status || 'planned'));
const objective = escHtml(String(e.objective_raw || '').slice(0, 120));
const tools = Array.isArray(e.tool_calls) ? e.tool_calls.length : 0;
const traceCount = Array.isArray(e?.decision_trace?.events) ? e.decision_trace.events.length : 0;
const repairs = Array.isArray(e?.verification?.repairs_applied) ? e.verification.repairs_applied.length : 0;
return `#${idx + 1} [${status}] ${objective || '(objective)'}
tools: ${tools}${traceCount ? ` • trace: ${traceCount}` : ''}${repairs ? ` • repairs: ${repairs}` : ''}
`;
}).join('');
} else {
const turns = data.recent_turns || [];
if (!turns.length) {
rt.innerHTML = 'No turn objectives yet.';
} else {
rt.innerHTML = turns.map(t => {
const kind = escHtml(String(t.kind || 'turn'));
const status = escHtml(String(t.status || 'open'));
const text = escHtml(String(t.text || '').slice(0, 120));
return `[${kind}/${status}] ${text}
`;
}).join('');
}
}
}
const toggle = document.getElementById('agent-mode-toggle');
const label = document.getElementById('agent-mode-label');
if (toggle) {
const locked = data.mode_lock === 'agent' || data.mode_lock === 'chat';
toggle.disabled = !!locked;
if (data.mode_lock === 'agent') {
toggle.checked = true;
useAgentMode = true;
} else if (data.mode_lock === 'chat') {
toggle.checked = false;
useAgentMode = false;
}
}
if (label) {
label.textContent = useAgentMode ? '에이전트' : '채팅';
}
maybeAutoScrollRightColumn(rightColumnAutoFollow && isThinking);
} catch {
const rt = document.getElementById('r-recent-turns');
if (rt) rt.innerHTML = 'Unable to load session context.';
}
}
function renderLiveStepsPanel() {
const el = document.getElementById('r-live-steps');
if (!el) return;
if (!liveTaskSteps.length) {
el.innerHTML = '활성 도구 단계 없음.
';
return;
}
el.innerHTML = liveTaskSteps.slice(-20).map((s) => {
const action = escHtml(String(s.action || 'tool'));
const status = escHtml(String(s.status || 'pending'));
const stepNum = Number(s.stepNum || 0);
const meta = s.meta ? `${escHtml(String(s.meta).slice(0, 140))}
` : '';
return `
${stepNum ? `Step ${stepNum}: ` : ''}${action}
${status.replace('_', ' ')}
${meta}
`;
}).join('');
}
function upsertLiveStep(action, stepNum, status, meta = '') {
const key = `${String(action || '')}:${Number(stepNum || 0)}`;
const idx = liveTaskSteps.findIndex(s => s.key === key);
const row = { key, action: String(action || ''), stepNum: Number(stepNum || 0), status: String(status || 'pending'), meta: String(meta || '') };
if (idx >= 0) liveTaskSteps[idx] = { ...liveTaskSteps[idx], ...row };
else liveTaskSteps.push(row);
renderLiveStepsPanel();
}
async function openSettings(tab) {
document.getElementById('settings-modal').style.display = 'flex';
setSettingsTab(tab || settingsTab || 'system');
try {
const status = await api('/api/status');
document.getElementById('settings-runtime-model').textContent = status.currentModel || '-';
loadOrchestrationConfig();
document.getElementById('settings-runtime-gateway').textContent = status.gateway || '-';
document.getElementById('settings-runtime-ollama').textContent = status.ollama ? 'Online' : 'Offline';
} catch {}
try {
const paths = await api('/api/settings/paths');
document.getElementById('settings-workspace-path').value = paths.workspace_path || '';
document.getElementById('settings-allowed-paths').value = (paths.allowed_paths || []).join('\n');
document.getElementById('settings-blocked-paths').value = (paths.blocked_paths || []).join('\n');
} catch {}
try {
const s = await api('/api/settings/search');
document.getElementById('settings-provider').value = s.preferred_provider || 'ddg';
document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified';
if (document.getElementById('settings-search-ollama-model'))
document.getElementById('settings-search-ollama-model').value = s.ollama_model || '';
if (document.getElementById('settings-search-searxng-url'))
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
toggleOllamaModelRow(s.preferred_provider);
toggleSearxngUrlRow(s.preferred_provider);
_searchSettingsLoaded = true;
// Keys are loaded via the Credentials tab — not here
} catch {}
try {
const p = await api('/api/settings/agent');
document.getElementById('settings-force-web-fresh').checked = p.force_web_for_fresh !== false;
document.getElementById('settings-memory-fallback').checked = p.memory_fallback_on_search_failure !== false;
document.getElementById('settings-auto-store-web-facts').checked = p.auto_store_web_facts !== false;
document.getElementById('settings-nl-tool-router').checked = p.natural_language_tool_router !== false;
document.getElementById('settings-retrieval-mode').value = p.retrieval_mode || 'standard';
} catch {}
}
// --- Users Tab ---------------------------------------------------------
async function loadUsersTab() {
const container = document.getElementById('users-list');
if (!container) return;
try {
const data = await api('/api/users');
if (!data || !data.users) { container.innerHTML = '인증이 비활성화되어 있거나 권한이 없습니다.
'; return; }
if (data.users.length === 0) {
container.innerHTML = '등록된 사용자가 없습니다.
';
return;
}
container.innerHTML = data.users.map(u => `
${u.username.charAt(0).toUpperCase()}
${u.username} (${u.role})
${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}
✈ 텔레그램
✉ 이메일
${u.username !== data.current_user ? `
삭제 ` : '
현재 '}
`).join('');
} catch (e) {
container.innerHTML = '사용자 목록을 불러올 수 없습니다.
';
}
}
async function addUser() {
const username = document.getElementById('new-user-username').value.trim();
const password = document.getElementById('new-user-password').value;
const role = document.getElementById('new-user-role').value;
const errEl = document.getElementById('add-user-error');
errEl.classList.remove('visible');
try {
const data = await api('/api/users', { method: 'POST', body: JSON.stringify({ username, password, role }) });
if (data.success) {
document.getElementById('new-user-username').value = '';
document.getElementById('new-user-password').value = '';
loadUsersTab();
} else {
errEl.textContent = data.error || '사용자 추가 실패';
errEl.classList.add('visible');
}
} catch (e) {
errEl.textContent = e.message || '사용자 추가 실패';
errEl.classList.add('visible');
}
}
async function removeUser(username) {
if (!confirm(`사용자 "${username}"를 삭제하시겠습니까?`)) return;
try {
await api(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
loadUsersTab();
} catch (e) { alert(e.message || '삭제 실패'); }
}
// --- Per-User Telegram Bot ---------------------------------------------------
function toggleUserTelegram(username) {
const panel = document.getElementById(`user-telegram-panel-${username}`);
if (!panel) return;
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadUserTelegramBot(username);
} else {
panel.style.display = 'none';
}
}
async function loadUserTelegramBot(username) {
const panel = document.getElementById(`user-telegram-panel-${username}`);
if (!panel) return;
panel.innerHTML = '불러오는 중...
';
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`);
const statusColor = data.running ? 'var(--ok)' : 'var(--muted)';
const statusText = data.running ? `연결됨 (@${data.botUsername || '?'})` : '중지됨';
panel.innerHTML = `
텔레그램 전용 봇 — ${username}
● ${statusText}
저장
${data.hasToken ? `삭제 ` : ''}
`;
} catch (e) {
panel.innerHTML = `${e.message}
`;
}
}
async function saveUserTelegramBot(username) {
const input = document.getElementById(`tg-token-${username}`);
const msg = document.getElementById(`tg-bot-msg-${username}`);
const token = input ? input.value.trim() : '';
if (!token) { if (msg) msg.textContent = '토큰을 입력해 주세요.'; return; }
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ botToken: token }),
});
if (data.success) {
if (msg) { msg.style.color = 'var(--ok)'; msg.textContent = '저장됨. 봇 연결 중...'; }
setTimeout(() => loadUserTelegramBot(username), 2000);
} else {
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '저장 실패'; }
}
} catch (e) {
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; }
}
}
async function deleteUserTelegramBot(username) {
if (!confirm(`${username}의 텔레그램 봇을 삭제하시겠습니까?`)) return;
const msg = document.getElementById(`tg-bot-msg-${username}`);
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ botToken: '' }),
});
if (data.success) loadUserTelegramBot(username);
else if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '삭제 실패'; }
} catch (e) {
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; }
}
}
// --- Per-User Email Accounts -------------------------------------------------
function toggleUserEmail(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadUserEmailAccounts(username);
} else {
panel.style.display = 'none';
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
async function loadUserEmailAccounts(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
panel.innerHTML = '불러오는 중…
';
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
const accounts = data.accounts || [];
const def = data.default || '';
const formId = `email-form-${username}`;
const list = accounts.length === 0
? '등록된 이메일 계정이 없습니다.
'
: accounts.map(a => `
${escapeHtml(a.id)}
${def === a.id ? '기본 ' : ''}
${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port}
${a.password_set ? '' : '비번 없음 '}
${def !== a.id ? `기본 설정 ` : ''}
테스트
삭제
`).join('');
panel.innerHTML = `
이메일 계정 (${accounts.length})
${list}
+ 계정 추가/수정
`;
} catch (e) {
panel.innerHTML = `불러오기 실패: ${escapeHtml(e.message || e)}
`;
}
}
async function submitUserEmailAccount(username) {
const form = document.getElementById(`email-form-${username}`);
const msg = document.getElementById(`email-form-${username}-msg`);
if (!form) return;
const get = (k) => form.querySelector(`[data-f="${k}"]`);
const id = (get('id').value || '').trim();
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { msg.textContent = '계정 ID는 1-32자 영숫자/대시/언더스코어'; return; }
const body = {
imap_host: get('imap_host').value.trim(),
imap_port: Number(get('imap_port').value) || 993,
imap_tls: get('imap_tls').checked,
smtp_host: get('smtp_host').value.trim(),
smtp_port: Number(get('smtp_port').value) || 587,
smtp_secure: get('smtp_secure').checked,
username: get('username').value.trim(),
password: get('password').value,
};
if (!body.imap_host || !body.smtp_host || !body.username) { msg.textContent = 'IMAP/SMTP 호스트와 사용자명은 필수'; return; }
msg.textContent = '저장 중…'; msg.style.color = 'var(--muted)';
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
method: 'PUT', body: JSON.stringify(body),
});
loadUserEmailAccounts(username);
} catch (e) {
msg.textContent = e.message || '저장 실패'; msg.style.color = 'var(--err)';
}
}
async function deleteUserEmailAccount(username, accountId) {
if (!confirm(`계정 "${accountId}" 삭제하시겠습니까? (vault의 비밀번호도 함께 삭제됩니다)`)) return;
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '삭제 실패'); }
}
async function setDefaultUserEmailAccount(username, accountId) {
try {
await api(`/api/users/${encodeURIComponent(username)}/email/default`, {
method: 'PUT', body: JSON.stringify({ accountId }),
});
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '기본 설정 실패'); }
}
async function testUserEmailAccount(username, accountId, btn) {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '연결 중…';
try {
const r = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST' });
if (r.success) { btn.textContent = '✓ 성공'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); }
else { alert(`테스트 실패: ${r.error || 'unknown'}`); btn.textContent = orig; btn.disabled = false; }
} catch (e) {
alert(e.message || '테스트 실패'); btn.textContent = orig; btn.disabled = false;
}
}
// --- Context Viewer Tab -------------------------------------------------------
let cvAutoRefreshTimer = null;
async function loadContextViewerTab() {
const listEl = document.getElementById('cv-sessions-list');
const warnEl = document.getElementById('cv-warn-banner');
if (!listEl) return;
listEl.innerHTML = '로딩 중...
';
if (warnEl) warnEl.style.display = 'none';
try {
const data = await api('/api/admin/context-viewer');
if (!data || !data.sessions) {
listEl.innerHTML = '권한이 없거나 데이터를 불러올 수 없습니다.
';
return;
}
const sessions = data.sessions;
if (sessions.length === 0) {
listEl.innerHTML = '세션이 없습니다.
';
return;
}
// Token warning banner
const warnSessions = sessions.filter(s => s.usagePct >= 70);
if (warnEl && warnSessions.length > 0) {
warnEl.style.display = 'block';
warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => {
const color = s.usagePct >= 90 ? '#dc2626' : '#d97706';
return `${s.username} (${s.id.slice(0,8)}…) ${s.usagePct}%`;
}).join(' | ');
}
listEl.innerHTML = sessions.map(s => {
const pct = s.usagePct;
const barColor = pct >= 90 ? 'var(--err)' : pct >= 70 ? 'var(--warn)' : 'var(--ok)';
const lastActive = s.lastActiveAt ? new Date(s.lastActiveAt).toLocaleString() : '-';
const onlineDot = s.online
? ' '
: ' ';
return `
${onlineDot}
${escHtml(s.username)}
${s.id.slice(0,12)}…
${lastActive}
${pct >= 70 ? `${pct}% ` : ''}
✕
${s.contextTokenEstimate.toLocaleString()} / ${s.numCtx.toLocaleString()} tok
메시지 ${s.historyCount}개
${s.lastUserMessage ? `
사용자: ${escHtml(s.lastUserMessage)}
` : ''}
${s.lastAssistantMessage ? `
AI: ${escHtml(s.lastAssistantMessage)}
` : ''}
`;
}).join('');
} catch (e) {
listEl.innerHTML = `오류: ${e.message || '알 수 없는 오류'}
`;
}
}
async function loadBannedIpsTab() {
const listEl = document.getElementById('cv-banned-ips-list');
const statEl = document.getElementById('cv-banned-ips-stat');
if (!listEl) return;
listEl.innerHTML = '로딩 중...
';
if (statEl) statEl.textContent = '';
try {
const data = await api('/api/admin/banned-ips');
if (!data || !data.banned) {
listEl.innerHTML = '권한이 없거나 데이터를 불러올 수 없습니다.
';
return;
}
const banned = data.banned;
if (statEl) statEl.textContent = `현재 차단 ${banned.length}건 · 누적 밴 ${data.totalBans.toLocaleString()}건`;
if (banned.length === 0) {
listEl.innerHTML = '현재 차단된 IP가 없습니다.
';
return;
}
listEl.innerHTML = banned.map(b => `
${escHtml(b.ip)}
${escHtml(b.jail)}
${escHtml(b.bannedAt)}
`).join('');
} catch (e) {
listEl.innerHTML = `오류: ${e.message || '알 수 없는 오류'}
`;
}
}
async function cvDeleteSession(username, sessionId) {
try {
const res = await fetch(`/api/admin/sessions/${encodeURIComponent(username)}/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
const card = document.getElementById('cv-card-' + sessionId);
if (card) card.remove();
} catch (e) {
alert('삭제 실패: ' + (e.message || e));
}
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"');
}
// --- 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 = 'No agents found.
';
return;
}
el.innerHTML = agentsConfigList.map((a) => {
const selected = a.id === selectedAgentId;
const lastRun = a.lastRun?.finishedAt ? new Date(a.lastRun.finishedAt).toLocaleString() : 'never';
const heartbeat = a.lastHeartbeatAt ? new Date(a.lastHeartbeatAt).toLocaleString() : 'never';
const badge = a.default ? 'default ' : '';
return `
${escHtml(a.emoji || '🤖')}
${escHtml(a.name || a.id)}
${badge}
${escHtml(a.id)}
last run: ${escHtml(lastRun)}
last heartbeat: ${escHtml(heartbeat)}
`;
}).join('');
}
function findSelectedAgent() {
return agentsConfigList.find(a => a.id === selectedAgentId) || null;
}
function agentFormNew() {
selectedAgentId = '';
setAgentForm({
id: '',
name: '',
emoji: '',
description: '',
workspace: '',
model: '',
cronSchedule: '',
maxSteps: '',
default: false,
minimalPrompt: true,
canSpawn: false,
spawnAllowlist: [],
tools: {},
});
if (agentMdEditor) agentMdEditor.setValue('');
const mdPath = document.getElementById('agent-md-path');
if (mdPath) mdPath.textContent = '에이전트를 선택하거나 저장하면 AGENTS.md를 불러옵니다';
const resultEl = document.getElementById('agent-spawn-result');
if (resultEl) resultEl.textContent = '';
renderAgentsList();
}
async function selectAgent(id) {
selectedAgentId = id;
const selected = findSelectedAgent();
setAgentForm(selected);
renderAgentsList();
await loadSelectedAgentMd();
await loadAgentRunHistory();
}
async function loadAgentsTab() {
ensureAgentMdEditor();
try {
const data = await api('/api/agents');
agentsConfigList = Array.isArray(data?.agents) ? data.agents : [];
if (!selectedAgentId || !agentsConfigList.some(a => a.id === selectedAgentId)) {
selectedAgentId = data?.defaultAgentId || agentsConfigList[0]?.id || '';
}
renderAgentsList();
const selected = findSelectedAgent();
setAgentForm(selected);
if (selected) {
await loadSelectedAgentMd();
await loadAgentRunHistory();
} else {
agentFormNew();
}
} catch (err) {
addProcessEntry('error', `Failed to load agents: ${err.message}`);
}
}
async function saveAgentFromForm() {
const agent = getAgentFromForm();
if (!agent.id) {
alert('Agent ID is required');
return;
}
if (!agent.name) {
alert('Agent name is required');
return;
}
try {
const exists = agentsConfigList.some(a => a.id === agent.id);
const endpoint = exists ? `/api/agents/${encodeURIComponent(agent.id)}` : '/api/agents';
const method = exists ? 'PUT' : 'POST';
const data = await api(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent }),
});
if (!data?.success) throw new Error(data?.error || 'save failed');
selectedAgentId = data?.agent?.id || agent.id;
addProcessEntry('info', `Agent "${selectedAgentId}" saved.`);
await loadAgentsTab();
} catch (err) {
alert(`Failed to save agent: ${err.message}`);
}
}
async function deleteSelectedAgent() {
if (!selectedAgentId) return;
if (!confirm(`Delete agent "${selectedAgentId}"?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(selectedAgentId)}`, { method: 'DELETE' });
addProcessEntry('info', `Agent "${selectedAgentId}" deleted.`);
selectedAgentId = '';
await loadAgentsTab();
} catch (err) {
alert(`Failed to delete agent: ${err.message}`);
}
}
async function loadSelectedAgentMd() {
ensureAgentMdEditor();
if (!selectedAgentId || !agentMdEditor) return;
try {
const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`);
agentMdEditor.setValue(data?.content || '');
const pathEl = document.getElementById('agent-md-path');
if (pathEl) pathEl.textContent = data?.path || '';
} catch (err) {
addProcessEntry('error', `Failed to load AGENTS.md: ${err.message}`);
}
}
async function saveSelectedAgentMd() {
if (!selectedAgentId || !agentMdEditor) return;
try {
const content = agentMdEditor.getValue();
await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
addProcessEntry('final', `Saved AGENTS.md for "${selectedAgentId}".`);
} catch (err) {
alert(`Failed to save AGENTS.md: ${err.message}`);
}
}
async function runSelectedAgentOnce() {
if (!selectedAgentId) return;
const task = (document.getElementById('agent-spawn-task').value || '').trim();
if (!task) {
alert('Provide a task first.');
return;
}
const out = document.getElementById('agent-spawn-result');
if (out) out.textContent = '실행 중...';
try {
const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/spawn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task }),
});
const result = data?.result || {};
if (out) {
const head = result.success ? `Success (${result.durationMs || 0}ms)` : `Failed: ${result.error || 'unknown error'}`;
out.textContent = `${head}\n\n${String(result.result || '').slice(0, 2500)}`;
}
await loadAgentsTab();
} catch (err) {
if (out) out.textContent = `Run failed: ${err.message}`;
}
}
async function loadAgentRunHistory() {
const el = document.getElementById('agent-run-history');
if (!el) return;
if (!selectedAgentId) { el.innerHTML = ''; return; }
try {
const data = await api(`/api/agents/history?agentId=${encodeURIComponent(selectedAgentId)}&limit=12`);
const rows = Array.isArray(data?.history) ? data.history : [];
if (!rows.length) {
el.innerHTML = 'No runs yet.
';
return;
}
el.innerHTML = rows.map((r) => {
const color = r.success ? '#1a6e35' : '#9c1a1a';
const when = new Date(r.finishedAt || r.startedAt || Date.now()).toLocaleString();
const label = `${r.trigger || 'manual'} • ${r.durationMs || 0}ms`;
const preview = String(r.resultPreview || r.error || '').slice(0, 140);
return `
${r.success ? 'success' : 'failed'}
${escHtml(when)}
${escHtml(label)}
${escHtml(preview)}
`;
}).join('');
} catch (err) {
el.innerHTML = `Failed to load history: ${escHtml(err.message)}
`;
}
}
// --- Channels Settings Functions ---------------------------------------
let channelsStatusLoaded = false;
function setChannelStatus(idPrefix, state, message) {
const dot = document.getElementById(`${idPrefix}-status-dot`);
const text = document.getElementById(`${idPrefix}-status-text`);
if (!dot || !text) return;
if (state === 'ok') {
dot.style.background = 'var(--ok)';
text.style.color = 'var(--text)';
} else if (state === 'warn') {
dot.style.background = '#f0ad4e';
text.style.color = '#b87b00';
} else {
dot.style.background = '#ccc';
text.style.color = 'var(--muted)';
}
text.textContent = message || '미설정';
}
function readChannelPayload(channel) {
if (channel === '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;
const linkBtn = document.getElementById('kk-send-link-btn');
if (linkBtn) linkBtn.style.display = (n.hasRefreshToken) ? 'inline-block' : 'none';
} 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 kakaoSendChannelLink() {
const btn = document.getElementById('kk-send-link-btn');
const orig = btn?.textContent;
if (btn) btn.textContent = '전송 중...';
try {
const data = await api('/api/kakao/send-channel-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelUrl: 'http://pf.kakao.com/_KxjxexfX/chat' }),
});
if (btn) btn.textContent = data.success ? '✅ 전송됨!' : '❌ 실패';
} catch {
if (btn) btn.textContent = '❌ 실패';
}
setTimeout(() => { if (btn) btn.textContent = orig; }, 3000);
}
async function saveChannelSettings(channel) {
const payload = readChannelPayload(channel);
try {
const data = await api('/api/channels/config', {
method: 'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ channels: { [channel]: payload } }),
});
if (data?.success) {
addProcessEntry('final', `${channel} settings saved.`);
channelsStatusLoaded = false;
await loadChannelsStatus();
} else {
alert('Save failed: ' + (data?.error || 'unknown error'));
}
} catch (err) {
alert('Save failed: ' + err.message);
}
}
async function testChannel(channel) {
const btnId = document.getElementById('channel-test-btn')
? 'channel-test-btn'
: channel === 'telegram' ? 'tg-test-btn' : channel === 'discord' ? 'dc-test-btn' : 'wa-test-btn';
setButtonBusy(btnId, true, 'Testing...', 'Test');
try {
const payload = readChannelPayload(channel);
const data = await api(`/api/channels/test/${channel}`, {
method: 'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
});
if (data?.success) {
if (channel === 'telegram') setChannelStatus('tg', 'ok', `Valid bot @${data?.bot?.username || 'telegram'}`);
if (channel === 'discord') setChannelStatus('dc', 'ok', `Valid bot @${data?.bot?.username || 'discord'}`);
if (channel === 'whatsapp') setChannelStatus('wa', 'ok', `Valid phone ${data?.account?.display_phone_number || data?.account?.id || ''}`.trim());
addProcessEntry('final', `${channel} connection test passed.`);
} else {
alert(`${channel} test failed: ${data?.error || 'unknown error'}`);
}
} catch (err) {
alert(`${channel} test failed: ${err.message}`);
}
setButtonBusy(btnId, false, 'Testing...', 'Test');
}
async function sendChannelTest(channel) {
const btnId = document.getElementById('channel-send-test-btn')
? 'channel-send-test-btn'
: channel === 'telegram' ? 'tg-send-test-btn' : channel === 'discord' ? 'dc-send-test-btn' : 'wa-send-test-btn';
setButtonBusy(btnId, true, 'Sending...', 'Send Test');
try {
const payload = readChannelPayload(channel);
const data = await api(`/api/channels/send-test/${channel}`, {
method: 'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
});
if (data?.success) {
addProcessEntry('final', `Test message sent via ${channel}.`);
} else {
alert(`${channel} send-test failed: ${data?.error || 'unknown error'}`);
}
} catch (err) {
alert(`${channel} send-test failed: ${err.message}`);
}
setButtonBusy(btnId, false, 'Sending...', 'Send Test');
}
// ─── Email Account Management (Channel Settings) ─────────────────────────
let emailAccountsCache = [];
function getCurrentUsername() {
if (typeof currentUser === 'object' && currentUser?.username) return currentUser.username;
try { return localStorage.getItem(LAST_USER_KEY) || ''; } catch { return ''; }
}
async function loadEmailAccounts() {
const username = getCurrentUsername();
if (!username) {
document.getElementById('email-accounts-list').innerHTML = '사용자 정보를 불러올 수 없습니다.
';
return;
}
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
const accounts = data?.accounts || [];
emailAccountsCache = accounts;
const count = accounts.length;
if (count > 0) setChannelStatus('em', 'ok', `${count}개 계정 연결됨`);
else setChannelStatus('em', 'off', '미설정');
renderEmailAccountsList(accounts);
} catch (err) {
document.getElementById('email-accounts-list').innerHTML = '이메일 계정을 불러올 수 없습니다.
';
}
}
function renderEmailAccountsList(accounts) {
const container = document.getElementById('email-accounts-list');
if (!accounts.length) {
container.innerHTML = '등록된 이메일 계정이 없습니다.
';
return;
}
container.innerHTML = accounts.map(a => {
const esc = s => String(s||'').replace(/
${esc(a.id)}
${esc(a.username)}
테스트
수정
삭제
IMAP: ${esc(a.imap_host)}:${a.imap_port} | SMTP: ${esc(a.smtp_host)}:${a.smtp_port}
`;
}).join('');
}
async function saveEmailAccount() {
const username = getCurrentUsername();
if (!username) { alert('사용자 정보를 확인할 수 없습니다.'); return; }
const id = document.getElementById('em-id').value.trim();
if (!id) { alert('계정 ID를 입력하세요.'); return; }
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { alert('계정 ID는 1-32자의 영문, 숫자, 대시, 언더스코어만 가능합니다.'); return; }
const username_field = document.getElementById('em-username').value.trim();
if (!username_field) { alert('이메일 주소를 입력하세요.'); return; }
const imap_host = document.getElementById('em-imap-host').value.trim();
if (!imap_host) { alert('IMAP 서버를 입력하세요.'); return; }
const smtp_host = document.getElementById('em-smtp-host').value.trim();
if (!smtp_host) { alert('SMTP 서버를 입력하세요.'); return; }
const payload = {
imap_host,
imap_port: Number(document.getElementById('em-imap-port').value) || 993,
imap_tls: !!document.getElementById('em-imap-tls').checked,
imap_reject_unauthorized: true,
smtp_host,
smtp_port: Number(document.getElementById('em-smtp-port').value) || 587,
smtp_secure: !!document.getElementById('em-smtp-secure').checked,
smtp_reject_unauthorized: true,
username: username_field,
password: document.getElementById('em-password').value || undefined,
};
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (data?.success) {
addProcessEntry('final', `Email account "${id}" saved.`);
document.getElementById('em-password').value = '';
await loadEmailAccounts();
} else {
alert('저장 실패: ' + (data?.error || '알 수 없는 오류'));
}
} catch (err) {
alert('저장 실패: ' + err.message);
}
}
async function testEmailAccount(accountId) {
const username = getCurrentUsername();
if (!username) return;
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, {
method: 'POST',
});
if (data?.success) {
addProcessEntry('final', `Email account "${accountId}" IMAP connection OK.`);
} else {
alert(`테스트 실패: ${data?.error || '알 수 없는 오류'}`);
}
} catch (err) {
alert('테스트 실패: ' + err.message);
}
}
function editEmailAccount(accountId) {
const acct = emailAccountsCache.find(a => a.id === accountId);
if (!acct) return;
document.getElementById('em-id').value = acct.id;
document.getElementById('em-username').value = acct.username || '';
document.getElementById('em-imap-host').value = acct.imap_host || '';
document.getElementById('em-imap-port').value = acct.imap_port || 993;
document.getElementById('em-smtp-host').value = acct.smtp_host || '';
document.getElementById('em-smtp-port').value = acct.smtp_port || 587;
document.getElementById('em-imap-tls').checked = acct.imap_tls !== false;
document.getElementById('em-smtp-secure').checked = acct.smtp_secure === true;
document.getElementById('em-password').value = '';
document.getElementById('em-password').placeholder = '•••••••• (변경 시에만 입력)';
}
async function deleteEmailAccount(accountId) {
if (!confirm(`이메일 계정 "${accountId}"를 삭제하시겠습니까?`)) return;
const username = getCurrentUsername();
if (!username) return;
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
});
if (data?.success) {
addProcessEntry('final', `Email account "${accountId}" deleted.`);
await loadEmailAccounts();
} else {
alert('삭제 실패: ' + (data?.error || '알 수 없는 오류'));
}
} catch (err) {
alert('삭제 실패: ' + err.message);
}
}
// ─── Heartbeat Notify Toggle ──────────────────────────────────────────────
function updateHbNotifyLabel() {
const on = document.getElementById('settings-hb-notify')?.checked;
const label = document.getElementById('settings-hb-notify-label');
if (label) label.textContent = on ? '알림 받기' : '알림 끄기';
}
async function saveHbNotifyConfig() {
const notifyEnabled = !!document.getElementById('settings-hb-notify')?.checked;
try {
const data = await api('/api/heartbeat/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notifyEnabled }),
});
if (data?.success) {
updateHbNotifyLabel();
addProcessEntry('final', notifyEnabled ? '허트비트 알림 켜짐' : '허트비트 알림 꺼짐');
}
} catch {}
}
async function saveSettings() {
const workspace_path = document.getElementById('settings-workspace-path').value.trim();
const allowed_paths = document.getElementById('settings-allowed-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
const blocked_paths = document.getElementById('settings-blocked-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
// If search settings were never loaded from the server (API failed or race with async load),
// fetch current values now so we don't overwrite searxng_url / preferred_provider with HTML defaults.
if (!_searchSettingsLoaded) {
try {
const s = await api('/api/settings/search');
document.getElementById('settings-provider').value = s.preferred_provider || '';
document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified';
if (document.getElementById('settings-search-searxng-url'))
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
if (document.getElementById('settings-search-ollama-model'))
document.getElementById('settings-search-ollama-model').value = s.ollama_model || '';
_searchSettingsLoaded = true;
} catch {}
}
const payload = {
preferred_provider: document.getElementById('settings-provider').value,
search_rigor: document.getElementById('settings-search-rigor').value,
tavily_api_key: document.getElementById('cred-tavily-key')?.value.trim() || '',
google_api_key: document.getElementById('cred-google-key')?.value.trim() || '',
google_cx: document.getElementById('cred-google-cx')?.value.trim() || '',
brave_api_key: document.getElementById('cred-brave-key')?.value.trim() || '',
ollama_model: document.getElementById('settings-search-ollama-model')?.value.trim() || '',
searxng_url: document.getElementById('settings-search-searxng-url')?.value.trim() || '',
};
const policyPayload = {
force_web_for_fresh: document.getElementById('settings-force-web-fresh').checked,
memory_fallback_on_search_failure: document.getElementById('settings-memory-fallback').checked,
auto_store_web_facts: document.getElementById('settings-auto-store-web-facts').checked,
natural_language_tool_router: document.getElementById('settings-nl-tool-router').checked,
retrieval_mode: document.getElementById('settings-retrieval-mode').value,
};
// If model/provider settings were never loaded (models tab not opened), fetch and populate DOM now
// so buildProviderPayload() doesn't send HTML defaults (provider='ollama', empty model, etc.)
if (!_modelSettingsLoaded) {
try {
const data = await api('/api/settings/provider');
const llm = data?.llm || {};
const prov = llm.provider || '';
const pc = llm.providers || {};
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
if (prov) { const el = document.getElementById('settings-llm-provider'); if (el) el.value = prov; }
v('settings-ollama-endpoint', pc.ollama?.endpoint);
v('settings-llamacpp-endpoint', pc.llama_cpp?.endpoint);
v('settings-llamacpp-model', pc.llama_cpp?.model);
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
v('settings-lmstudio-model', pc.lm_studio?.model);
const savedPrimary = pc[prov]?.model || data?.models_primary || '';
if (savedPrimary) { const el = document.getElementById('settings-primary-model'); if (el) el.value = savedPrimary; }
_modelSettingsLoaded = true;
} catch {}
}
// Capture primaryModel AFTER _modelSettingsLoaded guard so the DOM value is correct
// even when the models tab was never opened in this settings session.
const primaryModel = (document.getElementById('settings-primary-model') || {}).value || '';
const modelPayload = {
ollama_endpoint: (document.getElementById('settings-ollama-endpoint') || {}).value || 'http://localhost:11434',
primary: primaryModel,
roles: {
manager: primaryModel,
executor: primaryModel,
verifier: primaryModel,
}
};
const providerPayload = buildProviderPayload();
// Keep llm.providers.ollama.model in sync with the primary model
// so getModelForRole() never falls back to the hardcoded 'qwen3:4b'.
if (providerPayload.providers && providerPayload.providers.ollama) {
providerPayload.providers.ollama.model = primaryModel || providerPayload.providers.ollama.model || '';
}
try {
await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ workspace_path, allowed_paths, blocked_paths }) });
await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) });
await api('/api/settings/agent', { method: 'POST', body: JSON.stringify(policyPayload) });
await api('/api/settings/model', { method: 'POST', body: JSON.stringify(modelPayload) });
await api('/api/settings/provider', { method: 'POST', body: JSON.stringify({ llm: providerPayload }) });
const owKey = document.getElementById('cred-ow-key')?.value?.trim();
if (owKey && owKey !== '••••••••') {
await api('/api/settings/openweather', { method: 'POST', body: JSON.stringify({ api_key: owKey }) });
}
await loadSearchSettingsSummary();
quickSearchRigor = payload.search_rigor || quickSearchRigor;
updateQuickModeUI();
addProcessEntry('final', '설정이 저장되었습니다.');
closeSettings();
} catch (err) {
addProcessEntry('error', `설정 저장 실패: ${err.message}`);
}
}
// Approve a memory suggestion shown in the process log
async function confirmMemory(index) {
const entry = processLogEntries[index];
if (!entry || entry.type !== 'memory') return;
const suggestion = entry.extra || {};
try {
addProcessEntry('info', 'Saving memory...');
const res = await api('/api/memory/confirm', { method: 'POST', body: JSON.stringify(suggestion) });
if (res && res.ok === false) {
addProcessEntry('error', `Memory save failed: ${res.error || JSON.stringify(res)}`);
return;
}
processLogEntries[index].type = 'result';
processLogEntries[index].content = `Saved memory: ${String(entry.content).slice(0,200)}`;
renderProcessLog();
addProcessEntry('final', 'Memory persisted.');
} catch (err) {
addProcessEntry('error', `Failed to save memory: ${err.message}`);
}
}
function rejectMemory(index) {
const entry = processLogEntries[index];
if (!entry || entry.type !== 'memory') return;
processLogEntries[index].type = 'warn';
processLogEntries[index].content = 'Memory suggestion rejected.';
renderProcessLog();
addProcessEntry('info', 'Memory suggestion rejected by user.');
}
// ---- Run mission ----
async function runMission() {
const input = document.getElementById('mission-input');
const btn = document.getElementById('run-btn');
const mission = input.value.trim();
if (!mission) return;
btn.disabled = true;
btn.innerHTML = ' Starting...';
try {
const res = await api('/api/jobs', { method: 'POST', body: JSON.stringify({ mission }) });
if (res.error) {
log(`Error: ${res.error}`, 'error');
} else {
log(`Mission started ? Job ${res.jobId.slice(0, 8)}...`, 'success');
input.value = '';
setTimeout(() => selectJob(res.jobId), 500);
}
} catch (err) {
log(`Failed: ${err.message}`, 'error');
}
btn.disabled = false;
btn.innerHTML = '? Run';
}
// ---- Sessions list ----
function renderSessionsList() {
const el = document.getElementById('jobs-list');
if (!chatSessions || chatSessions.length === 0) {
el.innerHTML = 'No chats yet. Start a new conversation.
';
return;
}
const sorted = [...chatSessions].sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt));
el.innerHTML = sorted.map(s => `
${s.unread ? '🔵 ' : ''}${escHtml(s.title || 'New chat')}
${sessionsEditMode ? `` : ''}
${s.automated ? 'Auto ' : ''}
${(s.history || []).length} msgs
${(() => { const fmtTok = n => n>=1e6?`${(n/1e6).toFixed(1)}M`:n>=1000?`${Math.round(n/1000)}k`:`${n}`; const real = Number(s.contextTokens||0); const ct = real || (Number(s.toolOverheadTokens||0) + Math.round((s.history||[]).reduce((a,m)=>a+(m.content||'').length,0)/4)); const mx = _appCtxMax; if (!ct) return ''; if (mx > 0) { const pct = Math.min(100, Math.round(ct/mx*100)); const col = pct>=80?'var(--err)':pct>=50?'var(--warn)':'var(--muted)'; return `${real?'':'~'}${fmtTok(ct)}/${fmtTok(mx)} `; } return `${real?'':'~'}${fmtTok(ct)} tok `; })()}
${timeAgo(s.updatedAt || s.createdAt)}
`).join('');
}
// ---- Select job ----
async function selectJob(jobId) {
selectedJobId = jobId;
setMode('chat');
renderJobsList(allJobs);
await refreshJobDetail(jobId);
clearInterval(pollInterval);
pollInterval = setInterval(async () => {
const job = allJobs.find(j => j.id === jobId);
if (job && ['planning', 'executing', 'verifying'].includes(job.status)) {
await refreshJobDetail(jobId);
} else {
clearInterval(pollInterval);
}
}, 2000);
}
async function refreshJobDetail(jobId) {
try {
const data = await api(`/api/jobs/${jobId}`);
renderJobDetail(data);
} catch {}
}
function renderJobDetail(data) {
const { job, tasks, artifacts, state } = data;
const content = document.getElementById('main-content');
const isRunning = ['planning','executing','verifying'].includes(job.status);
content.innerHTML = `
${escHtml(job.title)}
${isRunning ? ' ' : ''}${job.status}
ID: ${job.id.slice(0, 12)}...
${timeAgo(job.created_at)}
${state ? `
Mission
${escHtml(state.mission)}
` : ''}
${tasks && tasks.length > 0 ? `
Tasks (${tasks.length})
${tasks.map(t => `
${escHtml(t.description || '')}
${t.acceptance_criteria && t.acceptance_criteria.length > 0 ? `
${t.acceptance_criteria.map(c => `
${escHtml(c)}
`).join('')}
` : ''}
`).join('')}` : `
작업
${isRunning ? ' Planning tasks...' : 'No tasks'}
`}
${artifacts && artifacts.length > 0 ? `
Artifacts (${artifacts.length})
${artifacts.map(a => `
${a.content ? `
${escHtml(a.content.slice(0, 200))}${a.content.length > 200 ? '...' : ''}
` : ''}
`).join('')}` : ''}
${logLines.map(l => `
${l.text}
`).join('')}
`;
const lp = document.getElementById('log-panel');
if (lp) lp.scrollTop = lp.scrollHeight;
}
// ---- Stats ----
function updateStats(jobs) {
const totalEl = document.getElementById('r-total');
const runningEl = document.getElementById('r-running');
const modeEl = document.getElementById('r-completed');
if (totalEl) totalEl.textContent = String(chatSessions.length);
if (runningEl) runningEl.textContent = activeChatSessionId ? '1' : '0';
if (modeEl) modeEl.textContent = useAgentMode ? 'On' : 'Off';
}
// ---- Approvals ----
async function loadApprovals() {
try {
const approvals = await api('/api/approvals');
const el = document.getElementById('approvals-list');
if (!approvals || approvals.length === 0) {
el.innerHTML = '대기 중인 승인 없음
';
return;
}
el.innerHTML = approvals.map(a => `
${escHtml(a.action)}
${escHtml(a.reason || 'Approval required')}
? Approve
? Reject
`).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 =>
` ${escHtml(String(u))} `
).join('');
onlineEl.style.color = 'var(--text)';
}
}
} catch {
setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%');
setText('sys-cpu', '--%');
setText('sys-ram', '--%');
setText('sys-gpu', 'N/A');
setText('sys-vram', 'N/A');
setText('sys-ollama', 'Stats unavailable');
setText('sys-runtime-sub', 'Could not fetch system stats right now');
setMeter('sys-cpu-meter', 0);
setMeter('sys-ram-meter', 0);
setMeter('sys-gpu-meter', 0);
setMeter('sys-vram-meter', 0);
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 = '— select provider first — ';
modelSelect.value = '';
return;
}
let models = [];
const draftModel = getDraftModelForProvider(provider);
if (draftModel) models.push(draftModel);
if (provider === 'openai') {
try { await refreshOpenAIModels(true); } catch {}
models.push(...getSelectOptionValues('settings-openai-model'));
} else if (provider === 'openai_codex') {
models.push(...getSelectOptionValues('settings-codex-model'));
} else if (['ollama', 'llama_cpp', 'lm_studio'].includes(provider)) {
try {
const llm = buildProviderPayload();
llm.provider = provider;
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
const detected = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
models.push(...detected);
} catch {}
}
models = uniqueStrings([...(ORCH_STATIC_MODEL_OPTIONS[provider] || []), ...models]);
if (preferredModel && !models.includes(preferredModel)) {
models.unshift(preferredModel);
}
if (!models.length && SECONDARY_MODEL_DEFAULTS[provider]) {
models.push(SECONDARY_MODEL_DEFAULTS[provider]);
}
if (!models.length) {
modelSelect.innerHTML = '— 감지된 모델 없음 — ';
modelSelect.value = '';
return;
}
modelSelect.innerHTML = models.map(m => `${escHtml(m)} `).join('');
const selected = preferredModel || modelSelect.value || draftModel || SECONDARY_MODEL_DEFAULTS[provider] || models[0];
modelSelect.value = models.includes(selected) ? selected : models[0];
}
let orchActivePreset = '';
const ORCH_PRESETS = {
conservative: {
desc: '오케스트레이터가 최소한으로 개입 — 안정적이지만 느리게 반응',
fail_threshold: 3, stagnation: 5, loop_detect: 'true',
risk_files: 10, risk_ops: 400, no_progress_sec: 150,
preflight: 'off', allow_secondary_chat: 'false',
cooldown: 5, max_assists_turn: 2, max_assists_session: 10,
browser_calls_turn: 3, browser_max_items: 40, browser_forced_retries: 0,
preempt_enabled: 'false', preempt_threshold: 90, preempt_max_turn: 1, preempt_max_session: 1,
fileops_enabled: 'true', fileops_verify_create: 'false',
fileops_create_lines: 150, fileops_create_chars: 7000,
fileops_edit_lines: 25, fileops_edit_chars: 2000, fileops_edit_files: 2,
fileops_verify_lines: 50, fileops_verify_chars: 2500, fileops_watchdog: 5, fileops_checkpointing: 'true',
},
balanced: {
desc: '기본 권장값 — 대부분의 상황에 적합',
fail_threshold: 2, stagnation: 3, loop_detect: 'true',
risk_files: 6, risk_ops: 220, no_progress_sec: 90,
preflight: 'complex_only', allow_secondary_chat: 'false',
cooldown: 3, max_assists_turn: 3, max_assists_session: 18,
browser_calls_turn: 5, browser_max_items: 80, browser_forced_retries: 0,
preempt_enabled: 'false', preempt_threshold: 45, preempt_max_turn: 1, preempt_max_session: 3,
fileops_enabled: 'true', fileops_verify_create: 'true',
fileops_create_lines: 80, fileops_create_chars: 3500,
fileops_edit_lines: 12, fileops_edit_chars: 800, fileops_edit_files: 1,
fileops_verify_lines: 25, fileops_verify_chars: 1200, fileops_watchdog: 3, fileops_checkpointing: 'true',
},
aggressive: {
desc: '자주 적극적으로 개입 — 복잡한 멀티스텝 작업에 적합',
fail_threshold: 1, stagnation: 2, loop_detect: 'true',
risk_files: 3, risk_ops: 100, no_progress_sec: 45,
preflight: 'always', allow_secondary_chat: 'false',
cooldown: 2, max_assists_turn: 5, max_assists_session: 30,
browser_calls_turn: 8, browser_max_items: 120, browser_forced_retries: 2,
preempt_enabled: 'true', preempt_threshold: 30, preempt_max_turn: 2, preempt_max_session: 6,
fileops_enabled: 'true', fileops_verify_create: 'true',
fileops_create_lines: 40, fileops_create_chars: 1500,
fileops_edit_lines: 6, fileops_edit_chars: 400, fileops_edit_files: 1,
fileops_verify_lines: 15, fileops_verify_chars: 600, fileops_watchdog: 2, fileops_checkpointing: 'true',
},
};
function applyOrchPreset(name) {
const p = ORCH_PRESETS[name];
if (!p) return;
orchActivePreset = name;
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
set('orch-fail-threshold', p.fail_threshold);
set('orch-stagnation', p.stagnation);
set('orch-loop-detect', p.loop_detect);
set('orch-risk-files', p.risk_files);
set('orch-risk-ops', p.risk_ops);
set('orch-no-progress-sec', p.no_progress_sec);
set('orch-preflight-mode', p.preflight);
set('orch-allow-secondary-chat', p.allow_secondary_chat);
set('orch-cooldown-rounds', p.cooldown);
set('orch-max-assists-turn', p.max_assists_turn);
set('orch-max-assists-session', p.max_assists_session);
set('orch-browser-calls-turn', p.browser_calls_turn);
set('orch-browser-max-items', p.browser_max_items);
set('orch-browser-forced-retries', p.browser_forced_retries);
set('orch-preempt-enabled', p.preempt_enabled);
set('orch-preempt-threshold', p.preempt_threshold);
set('orch-preempt-max-turn', p.preempt_max_turn);
set('orch-preempt-max-session', p.preempt_max_session);
set('orch-fileops-enabled', p.fileops_enabled);
set('orch-fileops-verify-create', p.fileops_verify_create);
set('orch-fileops-create-lines', p.fileops_create_lines);
set('orch-fileops-create-chars', p.fileops_create_chars);
set('orch-fileops-edit-lines', p.fileops_edit_lines);
set('orch-fileops-edit-chars', p.fileops_edit_chars);
set('orch-fileops-edit-files', p.fileops_edit_files);
set('orch-fileops-verify-lines', p.fileops_verify_lines);
set('orch-fileops-verify-chars', p.fileops_verify_chars);
set('orch-fileops-watchdog-cycles', p.fileops_watchdog);
set('orch-fileops-checkpointing', p.fileops_checkpointing);
// highlight active button
['conservative','balanced','aggressive'].forEach(n => {
const btn = document.getElementById('orch-preset-' + n);
if (!btn) return;
btn.style.background = n === name ? '#eaf2ff' : '#fff';
btn.style.borderColor = n === name ? '#0d4faf' : 'var(--line)';
btn.style.color = n === name ? '#0d4faf' : '#5a6e8a';
});
const desc = document.getElementById('orch-preset-desc');
if (desc) desc.textContent = p.desc;
}
function detectOrchPreset(cfg) {
const t = cfg.triggers || {};
const l = cfg.limits || {};
if (t.consecutive_failures <= 1 && t.stagnation_rounds <= 2) return 'aggressive';
if (t.consecutive_failures >= 3 && t.stagnation_rounds >= 5) return 'conservative';
return 'balanced';
}
function toggleOrchAdvanced() {
const panel = document.getElementById('orch-advanced-panel');
const arrow = document.getElementById('orch-advanced-arrow');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
if (arrow) arrow.style.transform = open ? '' : 'rotate(90deg)';
}
// ── Voice Settings ───────────────────────────────────────────────────────────
async function loadVoiceSettings() {
try {
const cfg = await api('/api/settings/voice');
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
v('voice-stt-provider', cfg.stt?.provider);
v('voice-stt-model', cfg.stt?.model);
v('voice-stt-language', cfg.stt?.language);
v('voice-tts-provider', cfg.tts?.provider);
v('voice-tts-model', cfg.tts?.model);
} catch (e) { console.warn('loadVoiceSettings:', e); }
}
async function saveVoiceSettings() {
const body = {
stt: {
provider: document.getElementById('voice-stt-provider')?.value,
model: document.getElementById('voice-stt-model')?.value?.trim(),
language: document.getElementById('voice-stt-language')?.value?.trim(),
},
tts: {
provider: document.getElementById('voice-tts-provider')?.value,
model: document.getElementById('voice-tts-model')?.value?.trim(),
},
};
try {
await api('/api/settings/voice', { method: 'POST', body: JSON.stringify(body) });
addProcessEntry('final', '음성 설정이 저장되었습니다.');
} catch (err) {
addProcessEntry('error', '음성 설정 저장 실패: ' + err.message);
}
}
// ── Session Settings ─────────────────────────────────────────────────────────
async function loadSessionSettings() {
try {
const cfg = await api('/api/settings/session');
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = String(val); };
v('session-max-messages', cfg.maxMessages);
v('session-history-turns', cfg.historyTurns);
v('session-compaction', cfg.compactionThreshold);
v('session-memory-flush', cfg.memoryFlushThreshold);
} catch (e) { console.warn('loadSessionSettings:', e); }
}
async function saveSessionSettings() {
const body = {
maxMessages: Number(document.getElementById('session-max-messages')?.value || 120),
historyTurns: Number(document.getElementById('session-history-turns')?.value || 8),
compactionThreshold: Number(document.getElementById('session-compaction')?.value || 0.7),
memoryFlushThreshold: Number(document.getElementById('session-memory-flush')?.value || 0.75),
};
try {
await api('/api/settings/session', { method: 'POST', body: JSON.stringify(body) });
addProcessEntry('final', '세션 설정이 저장되었습니다.');
} catch (err) {
addProcessEntry('error', '세션 설정 저장 실패: ' + err.message);
}
}
async function loadOrchestrationConfig() {
try {
const cfg = await api('/api/orchestration/config');
const provEl = document.getElementById('orch-secondary-provider');
const failEl = document.getElementById('orch-fail-threshold');
const stagEl = document.getElementById('orch-stagnation');
const loopEl = document.getElementById('orch-loop-detect');
const riskFilesEl = document.getElementById('orch-risk-files');
const riskOpsEl = document.getElementById('orch-risk-ops');
const noProgressEl = document.getElementById('orch-no-progress-sec');
const preflightModeEl = document.getElementById('orch-preflight-mode');
const allowSecondaryChatEl = document.getElementById('orch-allow-secondary-chat');
const cooldownEl = document.getElementById('orch-cooldown-rounds');
const maxAssistTurnEl = document.getElementById('orch-max-assists-turn');
const maxAssistSessionEl = document.getElementById('orch-max-assists-session');
const browserCallsEl = document.getElementById('orch-browser-calls-turn');
const browserItemsEl = document.getElementById('orch-browser-max-items');
const browserRetriesEl = document.getElementById('orch-browser-forced-retries');
const preemptEnabledEl = document.getElementById('orch-preempt-enabled');
const preemptThresholdEl = document.getElementById('orch-preempt-threshold');
const preemptMaxTurnEl = document.getElementById('orch-preempt-max-turn');
const preemptMaxSessionEl = document.getElementById('orch-preempt-max-session');
const fileOpsEnabledEl = document.getElementById('orch-fileops-enabled');
const fileOpsVerifyCreateEl = document.getElementById('orch-fileops-verify-create');
const fileOpsCreateLinesEl = document.getElementById('orch-fileops-create-lines');
const fileOpsCreateCharsEl = document.getElementById('orch-fileops-create-chars');
const fileOpsEditLinesEl = document.getElementById('orch-fileops-edit-lines');
const fileOpsEditCharsEl = document.getElementById('orch-fileops-edit-chars');
const fileOpsEditFilesEl = document.getElementById('orch-fileops-edit-files');
const fileOpsVerifyLinesEl = document.getElementById('orch-fileops-verify-lines');
const fileOpsVerifyCharsEl = document.getElementById('orch-fileops-verify-chars');
const fileOpsWatchdogCyclesEl = document.getElementById('orch-fileops-watchdog-cycles');
const fileOpsCheckpointingEl = document.getElementById('orch-fileops-checkpointing');
if (provEl) provEl.value = cfg.secondary?.provider || '';
await updateOrchSecondaryModels(cfg.secondary?.model || '');
if (failEl) failEl.value = String(cfg.triggers?.consecutive_failures ?? 2);
if (stagEl) stagEl.value = String(cfg.triggers?.stagnation_rounds ?? 3);
if (loopEl) loopEl.value = String(cfg.triggers?.loop_detection ?? true);
if (riskFilesEl) riskFilesEl.value = String(cfg.triggers?.risky_files_threshold ?? 6);
if (riskOpsEl) riskOpsEl.value = String(cfg.triggers?.risky_tool_ops_threshold ?? 220);
if (noProgressEl) noProgressEl.value = String(cfg.triggers?.no_progress_seconds ?? 90);
if (preflightModeEl) preflightModeEl.value = String(cfg.preflight?.mode || 'complex_only');
if (allowSecondaryChatEl) allowSecondaryChatEl.value = String(cfg.preflight?.allow_secondary_chat === true);
if (cooldownEl) cooldownEl.value = String(cfg.limits?.assist_cooldown_rounds ?? 3);
if (maxAssistTurnEl) maxAssistTurnEl.value = String(cfg.limits?.max_assists_per_turn ?? 3);
if (maxAssistSessionEl) maxAssistSessionEl.value = String(cfg.limits?.max_assists_per_session ?? 18);
if (browserCallsEl) browserCallsEl.value = String(cfg.browser?.max_advisor_calls_per_turn ?? 5);
if (browserItemsEl) browserItemsEl.value = String(cfg.browser?.max_collected_items ?? 80);
if (browserRetriesEl) browserRetriesEl.value = String(cfg.browser?.max_forced_retries ?? 2);
if (preemptEnabledEl) preemptEnabledEl.value = String(cfg.preempt?.enabled === true);
if (preemptThresholdEl) preemptThresholdEl.value = String(cfg.preempt?.stall_threshold_seconds ?? 45);
if (preemptMaxTurnEl) preemptMaxTurnEl.value = String(cfg.preempt?.max_preempts_per_turn ?? 1);
if (preemptMaxSessionEl) preemptMaxSessionEl.value = String(cfg.preempt?.max_preempts_per_session ?? 3);
if (fileOpsEnabledEl) fileOpsEnabledEl.value = String(cfg.file_ops?.enabled !== false);
if (fileOpsVerifyCreateEl) fileOpsVerifyCreateEl.value = String(cfg.file_ops?.verify_create_always !== false);
if (fileOpsCreateLinesEl) fileOpsCreateLinesEl.value = String(cfg.file_ops?.primary_create_max_lines ?? 80);
if (fileOpsCreateCharsEl) fileOpsCreateCharsEl.value = String(cfg.file_ops?.primary_create_max_chars ?? 3500);
if (fileOpsEditLinesEl) fileOpsEditLinesEl.value = String(cfg.file_ops?.primary_edit_max_lines ?? 12);
if (fileOpsEditCharsEl) fileOpsEditCharsEl.value = String(cfg.file_ops?.primary_edit_max_chars ?? 800);
if (fileOpsEditFilesEl) fileOpsEditFilesEl.value = String(cfg.file_ops?.primary_edit_max_files ?? 1);
if (fileOpsVerifyLinesEl) fileOpsVerifyLinesEl.value = String(cfg.file_ops?.verify_large_payload_lines ?? 25);
if (fileOpsVerifyCharsEl) fileOpsVerifyCharsEl.value = String(cfg.file_ops?.verify_large_payload_chars ?? 1200);
if (fileOpsWatchdogCyclesEl) fileOpsWatchdogCyclesEl.value = String(cfg.file_ops?.watchdog_no_progress_cycles ?? 3);
if (fileOpsCheckpointingEl) fileOpsCheckpointingEl.value = String(cfg.file_ops?.checkpointing_enabled !== false);
syncOrchPreemptControls();
// Auto-detect and highlight matching preset
applyOrchPreset(detectOrchPreset(cfg));
// Check eligibility
const elig = await api('/api/orchestration/eligible');
const msg = document.getElementById('orch-eligibility-msg');
if (msg) {
if (elig.eligible) {
msg.textContent = '? Orchestration eligible — enable the skill in the Skills tab.';
msg.style.color = 'var(--green, #4ade80)';
} else {
msg.textContent = elig.reason || 'Not eligible.';
msg.style.color = 'var(--muted)';
}
msg.style.display = 'block';
}
} catch {}
}
async function saveOrchestrationConfig() {
const provider = document.getElementById('orch-secondary-provider')?.value || '';
const model = document.getElementById('orch-secondary-model')?.value || '';
const failT = Number(document.getElementById('orch-fail-threshold')?.value || 2);
const stagT = Number(document.getElementById('orch-stagnation')?.value || 3);
const loopD = document.getElementById('orch-loop-detect')?.value !== 'false';
const riskFiles = Number(document.getElementById('orch-risk-files')?.value || 6);
const riskOps = Number(document.getElementById('orch-risk-ops')?.value || 220);
const noProgressSeconds = Number(document.getElementById('orch-no-progress-sec')?.value || 90);
const preflightMode = document.getElementById('orch-preflight-mode')?.value || 'complex_only';
const allowSecondaryChat = document.getElementById('orch-allow-secondary-chat')?.value === 'true';
const cooldownRounds = Number(document.getElementById('orch-cooldown-rounds')?.value || 3);
const maxAssistsTurn = Number(document.getElementById('orch-max-assists-turn')?.value || 3);
const maxAssistsSession = Number(document.getElementById('orch-max-assists-session')?.value || 18);
const browserCallsPerTurn = Number(document.getElementById('orch-browser-calls-turn')?.value || 5);
const browserMaxItems = Number(document.getElementById('orch-browser-max-items')?.value || 80);
const browserForcedRetries = Number(document.getElementById('orch-browser-forced-retries')?.value || 2);
const preemptEnabled = document.getElementById('orch-preempt-enabled')?.value === 'true';
const preemptThresholdSec = Number(document.getElementById('orch-preempt-threshold')?.value || 45);
const preemptMaxTurn = Number(document.getElementById('orch-preempt-max-turn')?.value || 1);
const preemptMaxSession = Number(document.getElementById('orch-preempt-max-session')?.value || 3);
const fileOpsEnabled = document.getElementById('orch-fileops-enabled')?.value !== 'false';
const fileOpsVerifyCreate = document.getElementById('orch-fileops-verify-create')?.value !== 'false';
const fileOpsCreateLines = Number(document.getElementById('orch-fileops-create-lines')?.value || 80);
const fileOpsCreateChars = Number(document.getElementById('orch-fileops-create-chars')?.value || 3500);
const fileOpsEditLines = Number(document.getElementById('orch-fileops-edit-lines')?.value || 12);
const fileOpsEditChars = Number(document.getElementById('orch-fileops-edit-chars')?.value || 800);
const fileOpsEditFiles = Number(document.getElementById('orch-fileops-edit-files')?.value || 1);
const fileOpsVerifyLines = Number(document.getElementById('orch-fileops-verify-lines')?.value || 25);
const fileOpsVerifyChars = Number(document.getElementById('orch-fileops-verify-chars')?.value || 1200);
const fileOpsWatchdogCycles = Number(document.getElementById('orch-fileops-watchdog-cycles')?.value || 3);
const fileOpsCheckpointing = document.getElementById('orch-fileops-checkpointing')?.value !== 'false';
try {
await api('/api/orchestration/config', {
method: 'POST',
body: JSON.stringify({
secondary: { provider, model },
triggers: {
consecutive_failures: failT,
stagnation_rounds: stagT,
loop_detection: loopD,
risky_files_threshold: riskFiles,
risky_tool_ops_threshold: riskOps,
no_progress_seconds: noProgressSeconds,
},
preflight: { mode: preflightMode, allow_secondary_chat: allowSecondaryChat },
limits: {
assist_cooldown_rounds: cooldownRounds,
max_assists_per_turn: maxAssistsTurn,
max_assists_per_session: maxAssistsSession,
},
browser: {
max_advisor_calls_per_turn: browserCallsPerTurn,
max_collected_items: browserMaxItems,
max_forced_retries: browserForcedRetries,
},
preempt: {
enabled: preemptEnabled,
stall_threshold_seconds: preemptThresholdSec,
max_preempts_per_turn: preemptMaxTurn,
max_preempts_per_session: preemptMaxSession,
},
file_ops: {
enabled: fileOpsEnabled,
primary_create_max_lines: fileOpsCreateLines,
primary_create_max_chars: fileOpsCreateChars,
primary_edit_max_lines: fileOpsEditLines,
primary_edit_max_chars: fileOpsEditChars,
primary_edit_max_files: fileOpsEditFiles,
verify_create_always: fileOpsVerifyCreate,
verify_large_payload_lines: fileOpsVerifyLines,
verify_large_payload_chars: fileOpsVerifyChars,
watchdog_no_progress_cycles: fileOpsWatchdogCycles,
checkpointing_enabled: fileOpsCheckpointing,
},
}),
});
const msg = document.getElementById('orch-eligibility-msg');
if (msg) {
msg.textContent = '? Saved. Now enable the Multi-Agent Orchestrator skill in the Skills tab.';
msg.style.color = 'var(--green, #4ade80)';
msg.style.display = 'block';
}
loadOrchestrationConfig(); // refresh eligibility
loadInstalledSkills(); // refresh skill gating state
} catch (err) {
alert('Failed to save: ' + (err.message || err));
}
}
function syncOrchPreemptControls() {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const note = document.getElementById('orch-preempt-note');
const fields = [
document.getElementById('orch-preempt-enabled'),
document.getElementById('orch-preempt-threshold'),
document.getElementById('orch-preempt-max-turn'),
document.getElementById('orch-preempt-max-session'),
].filter(Boolean);
const ollamaPrimary = provider === 'ollama';
fields.forEach(el => { el.disabled = !ollamaPrimary; });
if (note) note.style.display = ollamaPrimary ? 'none' : 'block';
}
// --- Skill card eligibility rendering ---------------------------------------
function renderSkillEligibility(skills) {
// Called after skills render — grey out ineligible skill cards
setTimeout(() => {
document.querySelectorAll('.skill-card').forEach(card => {
const skillId = card.dataset.skillId;
const skill = skills.find(s => s.id === skillId);
if (!skill) return;
if (skill.eligible === false) {
card.style.opacity = '0.5';
// Keep card clickable when already enabled so user can still disable it.
card.style.pointerEvents = skill.enabled ? 'auto' : 'none';
card.title = skill.eligibleReason || 'Not available';
const badge = document.createElement('div');
badge.textContent = skill.eligibleReason || 'Configure secondary model first';
badge.style = 'font-size:10px;color:var(--muted);margin-top:4px;';
if (!card.querySelector('.elig-badge')) {
badge.className = 'elig-badge';
card.appendChild(badge);
}
}
});
}, 50);
}
// --- Handle orchestration SSE events ----------------------------------------
function handleOrchestrationEvent(data) {
const panel =
document.getElementById('tool-calls-panel')
|| document.getElementById('steps-panel')
|| document.getElementById('current-turn-process')
|| document.getElementById('process-log');
if (!panel) return;
const el = document.createElement('div');
el.style = 'padding:6px 10px;margin:4px 0;background:linear-gradient(135deg,#1a1a2e,#16213e);border-left:3px solid #7c3aed;border-radius:4px;font-size:12px;color:#a78bfa;';
const modeLabel = data.mode === 'planner' ? 'Planner' : 'Rescue';
const routeText = data.route ? ` | route=${data.route}` : '';
const countText = (typeof data.assist_count === 'number' && typeof data.assist_cap === 'number')
? ` | assists ${data.assist_count}/${data.assist_cap}`
: '';
el.innerHTML = `Advisor (${modeLabel}) Trigger: ${data.reason || data.trigger}${routeText}${countText} `;
if (data.advice?.next_actions?.length) {
el.innerHTML += '? ' + data.advice.next_actions[0] + ' ';
}
if (data.preflight?.quick_plan?.length) {
el.innerHTML += '? ' + data.preflight.quick_plan[0] + ' ';
}
panel.appendChild(el);
panel.scrollTop = panel.scrollHeight;
}
// --- Expose to SSE handler ---------------------------------------------------
if (typeof window !== 'undefined') {
window._orchEventHandler = handleOrchestrationEvent;
}
// ---- Refresh all ----
async function refreshAll() {
renderSessionsList();
updateStats([]);
loadApprovals();
checkStatus();
loadSystemStats();
loadInstalledSkills();
loadSearchSettingsSummary();
loadAgentSessionContext();
_fetchAppCtxMax();
}
// ---- Log ----
const logLines = [];
function log(text, type = 'log') {
const ts = new Date().toLocaleTimeString();
logLines.push({ text: `[${ts}] ${text}`, type });
if (logLines.length > 100) logLines.shift();
const lp = document.getElementById('log-panel');
if (lp) {
lp.innerHTML = logLines.map(l => `${l.text}
`).join('');
lp.scrollTop = lp.scrollHeight;
}
}
// ---- Helpers ----
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"');
}
function _normalizeFileUrl(url) {
// Convert sandbox:/mnt/... or /mnt/user-data/... paths to /api/files/... URLs
if (/^sandbox:/i.test(url)) {
var p = url.replace(/^sandbox:\/*/i, '');
var idx = p.indexOf('uploads/');
return '/api/files/' + (idx !== -1 ? p.slice(idx) : 'uploads/' + p.split('/').pop());
}
if (/^\/mnt\/(user-data|data)\//i.test(url)) {
var idx = url.indexOf('uploads/');
return '/api/files/' + (idx !== -1 ? url.slice(idx) : 'uploads/' + url.split('/').pop());
}
return url;
}
function _renderLinkButton(text, url, opts) {
var hideDownload = opts && opts.hideDownload;
url = _normalizeFileUrl(url);
var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3'];
var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); })
|| fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; });
var decodedUrl = decodeURIComponent(url).toLowerCase();
var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf');
var isXlsx = /\.(xlsx|xls)(\?.*)?$/i.test(decodedUrl) || /\.(xlsx|xls)$/i.test(text.toLowerCase());
if (isPptx) {
var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9);
return '';
}
if (isPdf) {
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
return ''
+ '
'
+ '
'
+ ''
+ '
'
+ '
';
}
if (isXlsx) {
var xlsxSafeUrl = escHtml(url);
return ''
+ '
'
+ '
📊 '
+ '
' + escHtml(text) + ' '
+ '
✏️ 편집 '
+ '
↗ 새 창 '
+ '
⬇ 다운로드 '
+ '
'
+ ''
+ '
';
}
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
if (isTxt) {
var txtLabel = escHtml(text.replace(/^📝\s*/, ''));
return ''
+ '
'
+ '
📝 '
+ '
' + txtLabel + ' '
+ '
◀ 미리보기 '
+ '
⬇ 저장 '
+ '
'
+ '
'
+ '
';
}
if (isDownload) {
return ''
+ '⬇ ' + text + ' ';
}
return '' + text + ' ';
}
function togglePdfEmbed(url, containerId, btn) {
var container = document.getElementById(containerId);
if (!container) return;
var iframe = container.querySelector('iframe');
if (container.style.display === 'none') {
container.style.display = 'block';
if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정
btn.innerHTML = '▲ 닫기';
} else {
container.style.display = 'none';
btn.innerHTML = '◀ 미리보기';
}
}
var _luckyCurrentSaveUrl = null;
function openXlsxNewWindow(url, title) {
var q = '/xlsx-viewer.html?file=' + encodeURIComponent(url)
+ '&save=' + encodeURIComponent(url)
+ '&title=' + encodeURIComponent(title || '스프레드시트');
window.open(q, '_blank', 'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes');
}
function toggleXlsxEmbedBtn(btn) {
var url = encodeURI(btn.getAttribute('data-xlsx-url'));
var card = btn.closest('.xlsx-card');
var nameEl = card ? card.querySelector('span[style*="font-weight"]') : null;
var title = nameEl ? nameEl.textContent : url.split('/').pop();
var saveUrl = card ? card.querySelector('[data-xlsx-save-url]')?.getAttribute('data-xlsx-save-url') : null;
openLuckyModal(url, title, saveUrl);
}
function openLuckyModal(url, title, saveUrl) {
var modal = document.getElementById('lucky-modal');
var titleEl = document.getElementById('lucky-modal-title');
var container = document.getElementById('lucky-container');
if (!modal || !container) return;
// Reset box to centered state on each open
var box = document.getElementById('lucky-modal-box');
if (box) {
box.style.position = 'relative';
box.style.left = ''; box.style.top = '';
box.style.width = '96vw'; box.style.height = '92vh';
box._isAbsolute = false; box.dataset.minimized = '0';
}
modal.style.display = 'flex';
modal.style.alignItems = 'center';
modal.style.justifyContent = 'center';
titleEl.textContent = title || '스프레드시트';
_luckyCurrentSaveUrl = saveUrl || null;
container.innerHTML = 'Luckysheet 로딩 중… (최초 1회 ~3MB)
';
initLuckyModalInteraction();
fetch(url, { headers: authHeaders() })
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function(buf) {
loadLuckysheet(function() {
if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') {
container.innerHTML = 'Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.
'; return;
}
var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
container.innerHTML = '';
try { luckysheet.destroy(); } catch(e) {}
luckysheet.create({
container: 'lucky-container',
data: exportInfo.sheets,
title: '',
lang: 'en',
showinfobar: false,
showstatisticBar: false,
sheetBottomConfig: true,
allowUpdate: true,
enableAddRow: true,
enableAddCol: true,
showToolbar: true,
plugins: [],
defaultFontSize: 11,
defaultFontName: 'Nanum Gothic',
});
}, function(err) {
container.innerHTML = 'Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '
';
});
});
})
.catch(function(e) {
container.innerHTML = '불러오기 실패: ' + escHtml(e.message) + '
';
});
}
function minimizeLuckyModal() {
var box = document.getElementById('lucky-modal-box');
var modal = document.getElementById('lucky-modal');
var btn = event.target;
if (!box || !modal) return;
var minimized = box.dataset.minimized === '1';
if (minimized) {
box.style.height = box._savedHeight || '92vh';
box.dataset.minimized = '0';
btn.textContent = '–';
btn.title = '최소화';
if (!box._isAbsolute) { modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
} else {
box._savedHeight = box.style.height || '92vh';
box.dataset.minimized = '1';
btn.innerHTML = '◻';
btn.title = '복원';
if (box._isAbsolute) {
box.style.top = (window.innerHeight - 52) + 'px';
box.style.height = 'auto';
} else {
box.style.height = 'auto';
modal.style.alignItems = 'flex-end';
modal.style.justifyContent = 'center';
modal.style.paddingBottom = '0';
}
}
}
function closeLuckyModal() {
var modal = document.getElementById('lucky-modal');
var box = document.getElementById('lucky-modal-box');
if (modal) { modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
if (box) {
box.style.position = 'relative';
box.style.left = ''; box.style.top = '';
box.style.width = '96vw'; box.style.height = '92vh';
box._isAbsolute = false; box._savedHeight = null;
box.dataset.minimized = '0';
}
try { luckysheet.destroy(); } catch(e) {}
_luckyCurrentSaveUrl = null;
}
// Close on backdrop click
document.getElementById('lucky-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeLuckyModal();
});
function initLuckyModalInteraction() {
var box = document.getElementById('lucky-modal-box');
var modal = document.getElementById('lucky-modal');
if (!box || box._interactInit) return;
box._interactInit = true;
// Inject 8 resize handles
[
{ dir:'n', css:'top:0;left:10px;right:10px;height:6px;cursor:n-resize' },
{ dir:'s', css:'bottom:0;left:10px;right:10px;height:6px;cursor:s-resize' },
{ dir:'e', css:'right:0;top:10px;bottom:10px;width:6px;cursor:e-resize' },
{ dir:'w', css:'left:0;top:10px;bottom:10px;width:6px;cursor:w-resize' },
{ dir:'ne', css:'top:0;right:0;width:12px;height:12px;cursor:ne-resize' },
{ dir:'nw', css:'top:0;left:0;width:12px;height:12px;cursor:nw-resize' },
{ dir:'se', css:'bottom:0;right:0;width:12px;height:12px;cursor:se-resize' },
{ dir:'sw', css:'bottom:0;left:0;width:12px;height:12px;cursor:sw-resize' },
].forEach(function(d) {
var h = document.createElement('div');
h.className = 'lucky-rh';
h.dataset.dir = d.dir;
h.style.cssText = 'position:absolute;z-index:200;' + d.css;
box.appendChild(h);
});
function toAbsolute() {
if (box._isAbsolute) return;
var rect = box.getBoundingClientRect();
modal.style.display = 'block';
box.style.position = 'absolute';
box.style.left = rect.left + 'px';
box.style.top = rect.top + 'px';
box.style.width = rect.width + 'px';
box.style.height = rect.height + 'px';
box._isAbsolute = true;
}
// Title bar drag
var header = box.firstElementChild;
if (header) {
header.style.cursor = 'grab';
header.addEventListener('mousedown', function(e) {
if (e.target.tagName === 'BUTTON') return;
toAbsolute();
var rect = box.getBoundingClientRect();
var ox = e.clientX - rect.left, oy = e.clientY - rect.top;
header.style.cursor = 'grabbing';
function onMove(e) {
box.style.left = (e.clientX - ox) + 'px';
box.style.top = Math.max(0, e.clientY - oy) + 'px';
}
function onUp() {
header.style.cursor = 'grab';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
}
// Resize handle drag
box.addEventListener('mousedown', function(e) {
var handle = e.target.closest ? e.target.closest('.lucky-rh') : null;
if (!handle) return;
toAbsolute();
var dir = handle.dataset.dir;
var rect = box.getBoundingClientRect();
var sx = e.clientX, sy = e.clientY;
var sl = rect.left, st = rect.top, sw = rect.width, sh = rect.height;
document.body.style.userSelect = 'none';
document.body.style.cursor = handle.style.cursor;
function onMove(e) {
var dx = e.clientX - sx, dy = e.clientY - sy;
var nl = sl, nt = st, nw = sw, nh = sh;
if (dir.includes('e')) nw = Math.max(380, sw + dx);
if (dir.includes('s')) nh = Math.max(180, sh + dy);
if (dir.includes('w')) { nw = Math.max(380, sw - dx); nl = sl + sw - nw; }
if (dir.includes('n')) { nh = Math.max(180, sh - dy); nt = st + sh - nh; }
box.style.left = nl + 'px'; box.style.top = nt + 'px';
box.style.width = nw + 'px'; box.style.height = nh + 'px';
}
function onUp() {
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
e.stopPropagation();
});
}
// ── Voice: STT (Web Speech API — Chrome 내장 Google STT) ────────────────
var _speechRecog = null;
var _isRecording = false;
var _micAutosend = localStorage.getItem('micAutosend') !== 'false'; // default on
function updateMicAutosend() {
_micAutosend = document.getElementById('mic-autosend-toggle').checked;
localStorage.setItem('micAutosend', String(_micAutosend));
}
function initMicAutosendToggle() {
var toggle = document.getElementById('mic-autosend-toggle');
if (toggle) toggle.checked = _micAutosend;
}
function toggleMicRecording() {
if (_isRecording) stopMicRecording();
else startMicRecording();
}
function startMicRecording() {
var SpeechRecog = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecog) {
alert('이 브라우저는 음성 인식을 지원하지 않습니다.\nChrome을 사용해주세요.');
return;
}
_speechRecog = new SpeechRecog();
_speechRecog.lang = 'ko-KR';
_speechRecog.interimResults = false;
_speechRecog.maxAlternatives = 1;
_speechRecog.continuous = false;
var btn = document.getElementById('mic-btn');
_speechRecog.onstart = function() {
_isRecording = true;
if (btn) { btn.textContent = '⏹'; btn.title = '말하는 중… (클릭 시 중지)'; btn.classList.add('recording'); }
};
_speechRecog.onresult = function(e) {
var transcript = e.results[0][0].transcript;
var input = document.getElementById('chat-input');
if (input && transcript) {
input.value = (input.value ? input.value + ' ' : '') + transcript;
input.focus();
input.dispatchEvent(new Event('input'));
if (_micAutosend) setTimeout(function() { (_appSendFn || 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;
var _ttsAbort = null;
var _ttsSeqResolve = null; // resolves the currently-awaited piece's completion, so _ttsStop() can unblock the playback loop
var _appSendFn = null;
function initAppVoice(sendFn) {
_appSendFn = sendFn || null;
}
// Stops whatever is currently loading/playing (abort in-flight fetch, pause audio, reset button).
function _ttsStop() {
if (_ttsAbort) { _ttsAbort.abort(); _ttsAbort = null; }
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
if (_ttsSeqResolve) { var r = _ttsSeqResolve; _ttsSeqResolve = null; r(); }
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); _ttsBtnActive = null; }
}
// Splits raw text (before per-piece cleaning) into sentence/line-sized
// pieces synthesized and played back-to-back, instead of one big combined
// call. A single long combined call to OmniVoice measurably drops/garbles
// content (verified via STT round-trip, 2026-07-10, see project_tts_edge
// memory) — short calls are far more reliable. Splits on "\n" too, not just
// .!?, so bullet-list facts each become their own call rather than getting
// joined into one comma-separated blob (a comma pause alone wasn't enough —
// still failed reliably in testing). Tiny fragments get merged (mirrors
// voice-call.js's MIN_TTS_CHARS=15) so we don't fire a round-trip per clause.
// Splits raw text into pieces synthesized and played back-to-back, one pass:
// short sentence/line fragments accumulate into a buffer and flush once they
// cross MIN_CHARS (mirrors voice-call.js's MIN_TTS_CHARS=15, but lower — that
// threshold dates back to XTTS's streaming inference crashing on very short
// standalone text, which OmniVoice doesn't share; tested down to ~6 chars).
// Markdown table cells are the exception: each one is pushed as its own
// never-merged piece — tested 8/8 clean vs. frequent drops when cells get
// joined into one comma-list or merged across a row boundary (2026-07-10,
// e.g. "81%" vanishing, or "습도 바람 서울" nonsensically mixing header/data
// cells together). A separator row ("|---|---|") is dropped, it has no
// spoken content. Leading bullet markers get stripped per-piece too — once
// merged with plain spaces (not a real "\n"), _cleanForSpeech's ^...$/gm
// regexes only see the whole joined string as one line.
function _splitForSpeech(text) {
var out = [];
var buf = '';
var MIN_CHARS = 6;
function flush() { if (buf) { out.push(buf); buf = ''; } }
function addPiece(s, isLineBreak) {
s = s.trim();
if (!s) return;
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return; // separator row
var rowMatch = /^\|(.+)\|$/.exec(s);
if (rowMatch) {
flush(); // don't let a pending sentence fragment bleed into table cells
// Numbers are where the model actually loses reliability (measured
// repeatedly, project_tts_edge memory) — isolating every single cell
// fixed that but made a whole table read out painfully slowly (one
// network round-trip per cell). Words alone rarely garble, so merge
// consecutive non-numeric cells (e.g. city + weather condition) and
// only isolate cells that contain a digit — cuts call count roughly in
// half on a typical weather table while keeping the error-prone cells
// separate (reported 2026-07-10).
var cellBuf = '';
rowMatch[1].split('|').map(function(c) { return c.trim(); }).filter(Boolean).forEach(function(cell) {
if (/\d/.test(cell)) {
if (cellBuf) { out.push(cellBuf); cellBuf = ''; }
out.push(cell);
} else {
cellBuf = cellBuf ? cellBuf + ', ' + cell : cell;
}
});
if (cellBuf) out.push(cellBuf);
return;
}
var bulletStripped = s.replace(/^[-*+]\s+/, '');
if (!bulletStripped) return;
if (isLineBreak) {
// A line break (bullet item, paragraph line) is an intentional
// structural separation, same reasoning as table cells above — never
// merge it with a neighbor. Without this, a short line like "습도:
// 86%" (under MIN_CHARS) silently absorbed the next bullet line and
// the pause between them vanished (reported 2026-07-10). Plain prose
// sentences (.!? boundaries, not a line break) still merge below —
// that's normal conversational flow, not an itemized fact list.
flush();
out.push(bulletStripped);
return;
}
buf = buf ? buf + ' ' + bulletStripped : bulletStripped;
if (buf.replace(/[^\p{L}\p{N}]/gu, '').length >= MIN_CHARS) flush();
}
var start = 0;
for (var i = 0; i < text.length; i++) {
if (text[i] === '.' && /\d/.test(text[i - 1] || '') && /\d/.test(text[i + 1] || '')) continue; // decimal point, not a boundary
if (/[.!?\n]/.test(text[i])) {
var j = i + 1;
while (j < text.length && /[\s.!?]/.test(text[j])) j++;
addPiece(text.slice(start, j), text[i] === '\n');
start = j;
i = j - 1;
}
}
addPiece(text.slice(start), false);
flush();
return out.length ? out : [text];
}
async function _ttsPlaySequence(btn, pieces) {
for (var idx = 0; idx < pieces.length; idx++) {
if (_ttsBtnActive !== btn) return; // stopped/superseded before this piece started
var cleaned = _cleanForSpeech(pieces[idx]);
if (!cleaned) continue;
var ac = new AbortController();
_ttsAbort = ac;
var blob;
try {
var r = await fetch('/api/voice/tts', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
credentials: 'include',
body: JSON.stringify({ text: cleaned.slice(0, 2000) }),
signal: ac.signal,
});
if (!r.ok) throw new Error('TTS 오류 ' + r.status);
blob = await r.blob();
} catch (e) {
if (e.name === 'AbortError' || _ttsBtnActive !== btn) return;
_ttsStop();
alert('TTS 오류: ' + e.message);
return;
}
if (_ttsBtnActive !== btn) return; // superseded while this piece was fetching
var url = URL.createObjectURL(blob);
var audio = new Audio(url);
_ttsAudio = audio;
btn.textContent = '⏹';
var done = new Promise(function(resolve) {
_ttsSeqResolve = resolve;
audio.onended = function() { URL.revokeObjectURL(url); _ttsSeqResolve = null; resolve(); };
audio.onerror = function() { URL.revokeObjectURL(url); _ttsSeqResolve = null; resolve(); };
});
audio.play();
await done;
if (_ttsBtnActive !== btn) return; // stopped mid-playback
}
if (_ttsBtnActive === btn) _ttsStop(); // whole sequence finished naturally
}
// Shared core for playTTSText/playTTSMsg. Guards every async callback with a
// "is this still the active button?" check so a slow/stale response from a
// superseded click can never start overlapping playback (the echo bug).
function _ttsPlay(btn, text) {
if (!text) return;
var wasActive = (_ttsBtnActive === btn);
_ttsStop();
if (wasActive) return; // clicking the active button again = stop
btn.textContent = '⌛'; btn.classList.add('playing');
_ttsBtnActive = btn;
_ttsPlaySequence(btn, _splitForSpeech(text));
}
// Sino-Korean digit-string reading, e.g. 27 -> "이십칠", 0 -> "영". Handles
// 0-9999 (plenty for the measurement-style numbers TTS text actually
// contains — temperatures, percentages, speeds); larger numbers get an
// extra 만/억 grouping pass but aren't the target use case.
function _sinoKoreanInt(n) {
if (n === 0) return '영';
var digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'];
var smallUnits = ['', '십', '백', '천'];
var bigUnits = ['', '만', '억', '조'];
function fourDigit(num) {
if (num === 0) return '';
var s = '';
var ds = String(num).padStart(4, '0').split('').map(Number);
for (var i = 0; i < 4; i++) {
var d = ds[i], unit = smallUnits[3 - i];
if (d === 0) continue;
s += (d === 1 && unit !== '') ? unit : (digits[d] + unit);
}
return s;
}
var groups = [];
var rem = n;
while (rem > 0) { groups.push(rem % 10000); rem = Math.floor(rem / 10000); }
var result = '';
for (var g = groups.length - 1; g >= 0; g--) {
if (groups[g] === 0) continue;
result += fourDigit(groups[g]) + bigUnits[g];
}
return result || '영';
}
// "27.7" -> "이십칠점칠" (decimal digits read one at a time, standard
// Korean convention), "-3.2" -> "마이너스 삼점이".
function _numToHangulWord(numStr) {
var neg = numStr[0] === '-';
if (neg) numStr = numStr.slice(1);
var parts = numStr.split('.');
var word = _sinoKoreanInt(parseInt(parts[0], 10) || 0);
if (parts[1]) {
var d = ['영', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'];
word += '점' + parts[1].split('').map(function(c) { return d[+c]; }).join('');
}
return (neg ? '마이너스 ' : '') + word;
}
// Korean uses native-Korean numerals (하나/둘/셋...), not Sino-Korean
// (일/이/삼...), specifically for clock hours and hour-durations — "9시" is
// "아홉 시", never "구시" (reported 2026-07-10). Everything else (분, 초,
// 도, %, dates, etc.) correctly stays Sino-Korean via _numToHangulWord.
var _NATIVE_HOUR = { 1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯', 7: '일곱', 8: '여덟', 9: '아홉', 10: '열', 11: '열한', 12: '열두' };
function _hourReplacer(m, numStr, suffix) {
var n = parseInt(numStr, 10);
return _NATIVE_HOUR[n] ? _NATIVE_HOUR[n] + suffix : m;
}
// Strips markdown, emoji and bare parens before handing text to the GPU
// voice engine (OmniVoice). Mirrors voice-call.js's sanitizeForSpeech: emoji
// get mispronounced as garbled syllables, a lone "(" makes OmniVoice
// stop generating audio entirely instead of just mispronouncing it, and a
// bare "°C"/"°F" gets read as "그램"(grams) instead of degrees.
function _cleanForSpeech(text) {
return (text || '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/```[\s\S]*?```/g, '')
// Table handling was also never ported from voice-call.js — a leftover
// "|" right before a cell's content (e.g. "| 81% |") is yet another
// unknown-symbol case that makes OmniVoice choke and drop content
// (reported 2026-07-10, e.g. "81%" vanishing entirely from a table row).
.replace(/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/gm, ' ')
.replace(/^\s*\|(.+)\|\s*$/gm, function(_m, row) { return row.split('|').map(function(c) { return c.trim(); }).filter(Boolean).join(', '); })
// Bullet markers were never stripped here (unlike voice-call.js's
// sanitizeForSpeech, which already had this) — a leftover "- " right
// after the newline-to-comma pause below reads as a rushed, breathless
// run-on (reported 2026-07-10). Must run before \n gets consumed.
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/[#*`_~>]/g, ' ')
.replace(/(\d+(?:\.\d+)?)\s*°C/gi, '섭씨 $1도')
.replace(/(\d+(?:\.\d+)?)\s*°F/gi, '화씨 $1도')
.replace(/(\d+(?:\.\d+)?)\s*°/g, '$1도')
// Spell out common units so a bare symbol doesn't get read wrong
// (measured: numerals+symbols like "%"/"m/s" have a real error rate,
// spelling everything in Hangul is dramatically more reliable, 2026-07-10).
.replace(/(\d+(?:\.\d+)?)\s*%/g, '$1퍼센트')
.replace(/(\d+(?:\.\d+)?)\s*km\/h/gi, '$1킬로미터 매 시')
.replace(/(\d+(?:\.\d+)?)\s*m\/s/gi, '$1미터 매 초')
.replace(/(\d+(?:\.\d+)?)\s*mm/gi, '$1밀리미터')
.replace(/(\d+(?:\.\d+)?)\s*km/gi, '$1킬로미터')
// Wind-direction abbreviations in parens (e.g. "2.6 m/s (SSW)") aren't
// Korean words, so OmniVoice mangles them — spell out the compass point
// instead. Longest abbreviations first so "SSW" doesn't partial-match as
// "S" (reported 2026-07-10). Must run before the generic paren->period
// conversion below, while the parens are still intact to anchor on.
.replace(/\((NNE|ENE|ESE|SSE|SSW|WSW|WNW|NNW|NE|SE|SW|NW|N|S|E|W)\)/g, function(_m, dir) {
var d = { N: '북', NNE: '북북동', NE: '북동', ENE: '동북동', E: '동', ESE: '동남동', SE: '남동', SSE: '남남동', S: '남', SSW: '남남서', SW: '남서', WSW: '서남서', W: '서', WNW: '서북서', NW: '북서', NNW: '북북서' };
return d[dir] ? ' ' + d[dir] + ' ' : _m;
})
// Swap parens for a period rather than just erasing them — still no bare
// "(" reaches the model (the crash trigger), but a period gives the
// parenthetical aside a clearer pause than a comma did (measured: comma
// ~+0.02-0.08s over a bare space, period ~2-3x that, 2026-07-10).
.replace(/[((]\s*/g, '. ')
.replace(/\s*[))]\s*/g, '. ')
.replace(/\.\s*\./g, '.')
// Paragraph/line breaks were silently vanishing into the final \s+ ->
// ' ' collapse below, so a blank line between two unrelated facts (e.g.
// wind speed, then a separate rain/comfort sentence) got read with zero
// pause at all — worse than the old paren-as-space bug. Give them a
// pause proportional to how strong a break they represent.
.replace(/(?= 3) {
overlay = overlay || parts[0];
lat = parts[1];
lon = parts[2];
zoom = zoom || parts[3] || '7';
}
}
lat = lat || '37.5';
lon = lon || '126.9';
zoom = zoom || '7';
overlay = overlay || 'rain';
// Map radar overlay name
if (overlay === '-radar' || overlay === 'radar') overlay = 'radar';
return 'https://embed.windy.com/embed2.html?lat=' + lat + '&lon=' + lon +
'&zoom=' + zoom + '&level=' + level + '&overlay=' + overlay +
'&menu=&message=true&marker=&calendar=now&pressure=&type=map' +
'&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C';
}
return null;
} catch(e) { return null; }
}
function youtubeToEmbedUrl(url) {
try {
var u = new URL(url);
var id = null;
if (u.hostname === 'youtu.be') {
id = u.pathname.slice(1).split(/[/?]/)[0];
} else if (/youtube\.com/.test(u.hostname)) {
id = u.searchParams.get('v');
if (!id) {
var m = u.pathname.match(/\/(?:shorts|embed)\/([^/?]+)/);
if (m) id = m[1];
}
}
if (!id) return null;
var start = u.searchParams.get('t') || u.searchParams.get('start');
return 'https://www.youtube.com/embed/' + id + (start ? '?start=' + parseInt(start, 10) : '');
} catch(e) { return null; }
}
function mapsToEmbedUrl(url) {
try {
var u = new URL(url);
var q = null;
var placeMatch = u.pathname.match(/\/maps\/place\/([^/@?]+)/);
if (placeMatch) q = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
if (!q) {
var searchMatch = u.pathname.match(/\/maps\/search\/([^/@?]+)/);
if (searchMatch) q = decodeURIComponent(searchMatch[1].replace(/\+/g, ' '));
}
if (!q) {
var coordMatch = u.pathname.match(/\/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (coordMatch) q = coordMatch[1] + ',' + coordMatch[2];
}
if (!q) q = u.searchParams.get('q') || u.searchParams.get('query');
if (!q) return null;
return 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&output=embed&hl=ko';
} catch(e) { return null; }
}
function renderMarkdown(text, opts) {
if (!text) return '';
// Extract existing tags before escaping so they survive escHtml()
var imgPlaceholders = [];
var imgRe = / ]*class=["']md-img["'][^>]*>/gi;
var textSafe = text.replace(imgRe, function(m) {
var idx = imgPlaceholders.length;
imgPlaceholders.push(m);
return '\x00IMG' + idx + '\x00';
});
var html = escHtml(textSafe);
// Restore tags (already safe HTML)
imgPlaceholders.forEach(function(img, i) {
html = html.replace('\x00IMG' + i + '\x00', img);
});
// Code blocks (``` ... ```)
html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) {
var trimmed = code.trim();
if (lang === 'midi-widget') {
try {
var mwRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'");
var mwData = JSON.parse(mwRaw);
var mwFilename = mwData.filename || (mwData.saved_to ? mwData.saved_to.split('/').pop() : 'score.mid');
return buildMidiPickerWidget(mwData, mwFilename).html;
} catch(e) {
return '' + escHtml(trimmed) + ' ';
}
}
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
return '';
}
if (lang === 'tradingview') {
var tvSymbol = '', tvInterval = 'D';
// code content is already HTML-escaped at this point — unescape before parsing
var tvRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'").trim();
if (tvRaw.startsWith('{')) {
try {
var tvJson = JSON.parse(tvRaw);
tvSymbol = (tvJson.symbol || tvJson.SYMBOL || '').toString().toUpperCase();
tvInterval = (tvJson.interval || tvJson.INTERVAL || 'D').toString().toUpperCase();
} catch(e) { tvSymbol = tvRaw.split('|')[0].trim().toUpperCase(); }
} else {
var parts = tvRaw.split('|');
tvSymbol = (parts[0] || '').trim().toUpperCase();
tvInterval = (parts[1] || 'D').trim().toUpperCase();
}
if (!tvSymbol) return '' + trimmed + ' ';
var tvId = 'tv-' + Math.random().toString(36).slice(2, 9);
return '';
}
if (lang === 'kstock') {
var ksRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').trim();
var ksParts = ksRaw.split('|');
var ksCode = ksParts[0].trim().replace(/\D/g,''); // 숫자만 (005930)
var ksPeriod = (ksParts[1] || 'day').trim().toLowerCase();
var ksPeriodMap = { '1d':'day','day':'day','1w':'week','week':'week','1m':'month','month':'month','3m':'3month','3month':'3month','1y':'year','year':'year','3y':'3year','3year':'3year' };
var ksNaverPeriod = ksPeriodMap[ksPeriod] || 'day';
var ksPeriodLabel = { day:'일봉', week:'주봉', month:'월봉', '3month':'3개월', year:'1년', '3year':'3년' }[ksNaverPeriod] || ksNaverPeriod;
if (!ksCode) return '' + trimmed + ' ';
var ksId = 'ks-' + Math.random().toString(36).slice(2, 9);
var ksImgUrl = 'https://ssl.pstatic.net/imgfinance/chart/item/area/' + ksNaverPeriod + '/' + ksCode + '.png';
var ksNaverUrl = 'https://finance.naver.com/item/main.naver?code=' + ksCode;
return '';
}
return '' + trimmed + ' ';
});
// Inline code
html = html.replace(/`([^`]+)`/g, '$1');
// Images: 
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
var safeAlt = alt.replace(/"/g, '"');
var errAlt = (alt || '이미지').replace(/'/g, ''').replace(/✖ " + errAlt + "를 불러올 수 없습니다.')";
return ' ';
});
// Links: [text](url) — render download links for file types as buttons, PPTX gets preview
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) {
return _renderLinkButton(text, url, opts);
});
// Bare /api/files/... URLs — auto-convert to download buttons even without markdown syntax
html = html.replace(/(\s|^)(\/api\/files\/[^\s<"]+)/g, function(_, space, url) {
return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url, opts);
});
// YouTube URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:(?:www\.|m\.)?youtube\.com\/(?:watch|shorts|embed)|youtu\.be\/)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = youtubeToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '"');
var safeOrig = url.replace(/"/g, '"');
return pre + '' + post;
});
// Google Maps URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:www\.google\.com\/maps|maps\.google\.com|goo\.gl\/maps)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = mapsToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '"');
var safeOrig = url.replace(/"/g, '"');
return pre + '' + post;
});
// Windy URLs in (model used  syntax) → inline iframe [must run first]
html = html.replace(/ ]*src="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*\/?>/gi, function(m, url) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '"');
var safeOrig = url.replace(/"/g, '"');
return '';
});
// Windy URLs inside anchor tags (markdown links) → inline iframe [must run BEFORE bare-URL regex]
html = html.replace(/]*href="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*>[^<]*<\/a>/gi, function(m, url) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '"');
var safeOrig = url.replace(/"/g, '"');
return '';
});
// Windy URLs on their own line → inline iframe [runs after anchor regex to avoid double-render]
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '"');
var safeOrig = url.replace(/"/g, '"');
return pre + '' + post;
});
// Bare image URLs on their own line → inline image
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/[^\s<>"()\n#?]+\.(?:jpe?g|png|gif|webp|svg|bmp|avif|tiff)(?:[?#][^\s<>"()\n]*)?)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','✖ 이미지를 불러올 수 없습니다. ')";
return pre + ' ' + post;
});
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '$1 ');
// Italic
html = html.replace(/(?$1');
// Markdown tables → HTML (line-by-line, O(n), must run BEFORE \n → )
(function() {
var lines = html.split('\n');
var out = [];
var parseRow = function(line) {
var cells = line.trim().split('|');
if (cells.length && cells[0].trim() === '') cells.shift();
if (cells.length && cells[cells.length - 1].trim() === '') cells.pop();
return cells.map(function(c) { return c.trim(); });
};
var isSep = function(line) { return /^\|[\s|:\-]+\|[ \t]*$/.test(line.trim()); };
var isRow = function(line) { return /^\|.+\|[ \t]*$/.test(line.trim()); };
var i = 0;
while (i < lines.length) {
var line = lines[i];
if (isRow(line) && i + 1 < lines.length && isSep(lines[i + 1])) {
var tbl = [line, lines[i + 1]];
var j = i + 2;
while (j < lines.length && isRow(lines[j])) { tbl.push(lines[j]); j++; }
var headers = parseRow(tbl[0]);
var thead = '' + headers.map(function(h) { return '' + h + ' '; }).join('') + ' ';
var tbody = '' + tbl.slice(2).map(function(row) {
return '' + parseRow(row).map(function(c) { return '' + c + ' '; }).join('') + ' ';
}).join('') + ' ';
out.push('');
i = j;
continue;
}
out.push(line);
i++;
}
html = out.join('\n');
})();
// Line breaks
html = html.replace(/\n/g, ' ');
return html;
}
function renderTVChart(btn, containerId, symbol, interval) {
btn.disabled = true;
btn.textContent = '로딩 중...';
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
function doInit() {
try {
new TradingView.widget({
container_id: containerId,
autosize: true,
symbol: symbol,
interval: interval || 'D',
timezone: 'Asia/Seoul',
theme: isDark ? 'dark' : 'light',
style: '1',
locale: 'kr',
enable_publishing: false,
allow_symbol_change: true,
save_image: false,
withdateranges: true,
hide_side_toolbar: false,
studies: ['RSI@tv-basicstudies', 'MACD@tv-basicstudies'],
});
btn.style.display = 'none';
} catch(e) {
btn.disabled = false;
btn.textContent = '📈 차트 불러오기';
}
}
if (window.TradingView && window.TradingView.widget) {
doInit();
} else {
var s = document.createElement('script');
s.src = 'https://s3.tradingview.com/tv.js';
s.onload = doInit;
s.onerror = function() { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; };
document.head.appendChild(s);
}
}
function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId);
if (!widget) return;
// widgetId may point to either .music-widget or .music-widget-body
var body = widget.classList.contains('music-widget-body')
? widget
: widget.querySelector('.music-widget-body');
var btn = widget.classList.contains('music-widget-body')
? widget.closest('.music-widget')?.querySelector('[title="접기/펼치기"]')
: widget.querySelector('[title="접기/펼치기"]');
if (!body) return;
var collapsed = body.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? '▸' : '▾';
}
function closeMusicWidget(widgetId) {
var el = document.getElementById(widgetId);
if (!el) return;
var widget = el.closest('.music-widget') || el;
widget.remove();
}
// ── Web MIDI router ──────────────────────────────────────────────────────────
const midiRouter = {
access: null,
timers: [],
isPlaying: false,
stop() {
this.timers.forEach(t => clearTimeout(t));
this.timers = [];
this.isPlaying = false;
if (this.access) {
this.access.outputs.forEach(out => {
for (let ch = 0; ch < 16; ch++) {
try { out.send([0xB0 | ch, 123, 0]); } catch {} // All Notes Off
try { out.send([0xB0 | ch, 121, 0]); } catch {} // Reset All Controllers
}
});
}
},
// Parse raw MIDI bytes → scheduled events list
// Returns [{timeMs, msg:[...bytes]}, ...]
_parseMidiRaw(buf) {
const u8 = new Uint8Array(buf);
const dv = new DataView(buf);
if (dv.getUint32(0) !== 0x4D546864) return []; // not MThd
const tpq = dv.getUint16(12);
const ntrk = dv.getUint16(10);
let tempo = 500000;
const events = [];
const readVarLen = (pos) => {
let val = 0, b;
do { b = u8[pos++]; val = (val << 7) | (b & 0x7F); } while (b & 0x80);
return { val, pos };
};
const ticksToMs = (ticks) => (ticks / tpq) * (tempo / 1000);
let p = 14;
for (let t = 0; t < ntrk; t++) {
if (p + 8 > u8.length || dv.getUint32(p) !== 0x4D54726B) break;
const tlen = dv.getUint32(p + 4);
p += 8;
const end = p + tlen;
let tick = 0, rs = 0;
while (p < end) {
const dl = readVarLen(p); p = dl.pos; tick += dl.val;
let b = u8[p];
if (b & 0x80) { rs = b; p++; } else { b = rs; }
const typ = b & 0xF0, ch = b & 0x0F;
const ms = ticksToMs(tick);
if (typ === 0x90 || typ === 0x80) {
const note = u8[p++], vel = u8[p++];
events.push({ ms, msg: [typ | ch, note, vel] });
} else if (typ === 0xA0 || typ === 0xB0 || typ === 0xE0) {
events.push({ ms, msg: [b, u8[p], u8[p+1]] }); p += 2;
} else if (typ === 0xC0) {
events.push({ ms, msg: [0xC0 | ch, u8[p++]] });
} else if (typ === 0xD0) {
events.push({ ms, msg: [b, u8[p++]] });
} else if (b === 0xFF) {
const mt = u8[p++];
const ll = readVarLen(p); p = ll.pos;
if (mt === 0x51 && ll.val >= 3) {
// Tempo change: recalculate from current tick
tempo = (u8[p] << 16) | (u8[p+1] << 8) | u8[p+2];
}
p += ll.val;
} else if (b === 0xF0 || b === 0xF7) {
const ll = readVarLen(p); p = ll.pos + ll.val;
} else { p++; }
}
p = end;
}
events.sort((a, b) => a.ms - b.ms);
return events;
},
async play(midiUrl, outputId, onStatus) {
this.stop();
const output = this.access.outputs.get(outputId);
if (!output) throw new Error('기기를 찾을 수 없습니다');
if (onStatus) onStatus('로딩 중...');
const resp = await fetch(midiUrl);
const buf = await resp.arrayBuffer();
// Parse raw MIDI for exact Program Change + all events
const events = this._parseMidiRaw(buf);
if (!events.length) throw new Error('MIDI 파싱 실패');
// GM Reset SysEx → 신디를 GM 모드로 초기화
try { output.send(new Uint8Array([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7])); } catch {}
// Send all PC + Bank Select (CC#0=0, CC#32=0) events upfront
const initPc = new Set();
events.forEach(ev => {
if ((ev.msg[0] & 0xF0) === 0xC0 && !initPc.has(ev.msg[0] & 0x0F)) {
initPc.add(ev.msg[0] & 0x0F);
const ch = ev.msg[0] & 0x0F;
try { output.send(new Uint8Array([0xB0 | ch, 0, 0])); } catch {} // Bank Select MSB=0
try { output.send(new Uint8Array([0xB0 | ch, 32, 0])); } catch {} // Bank Select LSB=0
try { output.send(new Uint8Array(ev.msg)); } catch {}
}
});
const NOTE_DELAY = 200; // ms — give synth time to process GM reset + PCs
const totalMs = events[events.length - 1].ms + NOTE_DELAY + 500;
this.isPlaying = true;
if (onStatus) onStatus('재생 중...');
events.forEach(ev => {
this.timers.push(setTimeout(() => {
if (!this.isPlaying) return;
try { output.send(new Uint8Array(ev.msg)); } catch {}
}, ev.ms + NOTE_DELAY));
});
this.timers.push(setTimeout(() => {
this.isPlaying = false;
if (onStatus) onStatus('완료');
}, totalMs));
}
};
async function midiRouterToggle(routerId, btnId) {
const panel = document.getElementById(routerId);
if (!panel) { alert('패널을 찾을 수 없습니다: ' + routerId); return; }
const isHidden = panel.style.display === 'none' || panel.style.display === '';
if (isHidden) {
panel.style.display = 'flex';
panel.style.flexWrap = 'wrap';
panel.style.gap = '6px';
panel.style.alignItems = 'center';
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
try {
if (!navigator.requestMIDIAccess) throw new Error('Web MIDI 미지원 — Chrome/Edge 필요');
if (!midiRouter.access) {
midiRouter.access = await navigator.requestMIDIAccess();
}
if (sel) {
sel.innerHTML = '-- MIDI 기기 선택 -- ';
midiRouter.access.outputs.forEach((out, id) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = out.name; sel.appendChild(opt);
});
if (sel.options.length <= 1 && st) st.textContent = 'MIDI 출력 기기 없음';
}
} catch (e) {
if (st) st.textContent = '오류: ' + e.message;
}
} else {
panel.style.display = 'none';
midiRouter.stop();
}
}
async function midiRouterPlay(routerId, midiUrl) {
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
const outputId = sel ? sel.value : '';
if (!outputId) { if (st) st.textContent = '기기를 선택하세요'; return; }
try {
await midiRouter.play(midiUrl, outputId, msg => { if (st) st.textContent = msg; });
} catch (e) { if (st) st.textContent = '오류: ' + e.message; }
}
function midiRouterStop(routerId) {
midiRouter.stop();
const st = document.getElementById(routerId + '-st');
if (st) st.textContent = '정지';
}
function buildMusicActions(data, downloadName) {
var midiUrl = data.midi_url;
var pdfUrl = data.pdf_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var html = '';
if (data.saved_to) {
html += '
💾 ' + escHtml(data.saved_to) + '
';
}
if (midiUrl) {
html += '
'
+ '-- MIDI 기기 선택 -- '
+ '▶ '
+ '■ '
+ ' '
+ '
';
html += '
▶ 외부 신디 ';
html += '
🎹 편집 ';
html += '
⬇ MIDI ';
}
if (pdfUrl) html += '
⬇ PDF ';
html += '
';
return html;
}
// ─────────────────────────────────────────────────────────────────────────────
function buildSvgPagesHtml(data) {
var pages = data.page_count || 1;
var baseId = data.id;
// Use a random suffix so duplicate renders in chat history never share the same navId
var navId = 'music-nav-' + Math.random().toString(36).slice(2, 10);
var src1 = pages === 1 ? data.svg_url : '/api/music/file/' + baseId + '/svg/1';
var nav = '';
if (pages > 1) {
nav = '' +
'◀ ' +
'1 / ' + pages + ' ' +
'▶ ' +
'
';
}
return nav + ' ';
}
function musicPageNav(navId, delta) {
var nav = document.getElementById(navId);
if (!nav) return;
var cur = parseInt(nav.dataset.page, 10);
var total = parseInt(nav.dataset.total, 10);
var next = cur + delta;
if (next < 1 || next > total) return;
nav.dataset.page = String(next);
var baseId = nav.dataset.id;
var img = document.getElementById(navId + '-img');
var lbl = document.getElementById(navId + '-label');
var prev = document.getElementById(navId + '-prev');
var nxt = document.getElementById(navId + '-next');
var url = '/api/music/file/' + baseId + '/svg/' + next;
if (img) img.src = url;
if (lbl) lbl.textContent = next + ' / ' + total;
if (prev) prev.disabled = next <= 1;
if (nxt) nxt.disabled = next >= total;
}
async function renderMusicBlock(widgetId, source) {
var widget = document.getElementById(widgetId);
if (!widget) return;
var btn = widget.querySelector('.music-render-btn');
var output = widget.querySelector('.music-output');
if (btn) { btn.disabled = true; btn.textContent = '렌더링 중…'; }
try {
var data = await api('/api/music/render', {
method: 'POST',
body: JSON.stringify({ source: source }),
});
if (data.error) throw new Error(data.error);
var html = buildSvgPagesHtml(data);
html += buildMusicActions(data, 'score.midi');
if (output) {
output.innerHTML = html;
output.style.display = 'block';
// Update chatHistory so the rendered output survives page reload
persistActiveChat();
}
if (btn) { btn.textContent = '다시 렌더링'; btn.disabled = false; }
} catch (e) {
if (output) {
output.innerHTML = '렌더링 실패: ' + escHtml(e.message) + ' ';
output.style.display = 'block';
}
if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; }
}
}
// ─── MIDI 업로드 → 액션 피커 ──────────────────────────────────────────────────
function buildMidiPickerWidget(data, filename) {
var wid = 'midi-' + Math.random().toString(36).slice(2, 10);
var midiUrl = data.midi_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var savedLine = data.saved_to
? '💾 ' + escHtml(data.saved_to) + '
'
: '';
// MIDI player panel (hidden by default) — includes MIDI download
var playerPanel =
'' +
'
-- MIDI 기기 선택 -- ' +
'
▶ ' +
'
■ ' +
'
⬇ MIDI ' +
'
' +
'
';
// 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 = '' + scoreInner + '
';
var scoreBtnLabel = '🎼 악보';
var html =
'';
return { wid, html };
}
async function midiPickerScore(wid, midiId, filename) {
var scoreDiv = document.getElementById(wid + '-score');
var btn = document.getElementById(wid + '-score-btn');
if (!scoreDiv) return;
// If score already rendered, toggle visibility
if (scoreDiv.style.display !== 'none' && scoreDiv.innerHTML.trim()) {
scoreDiv.style.display = 'none';
if (btn) btn.textContent = '📄 악보';
return;
}
if (scoreDiv.innerHTML.trim()) {
scoreDiv.style.display = '';
if (btn) btn.textContent = '📄 닫기';
return;
}
// Show progress
scoreDiv.style.display = '';
scoreDiv.innerHTML = '📄 악보 변환 중...
';
if (btn) btn.disabled = true;
try {
var resp = await fetch('/api/music/midi-score', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ id: midiId }),
});
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split('\n\n'); buf = parts.pop() || '';
for (var i = 0; i < parts.length; i++) {
if (!parts[i].startsWith('data: ')) continue;
var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; }
if (ev.type === 'progress') {
var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message;
} else if (ev.type === 'done') {
scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename);
if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; }
} else if (ev.type === 'error') {
scoreDiv.innerHTML = '악보 변환 실패: ' + escHtml(ev.message) + ' ';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
}
} catch (e) {
scoreDiv.innerHTML = '오류: ' + escHtml(e.message) + ' ';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-upload-' + Date.now();
appendChatBubble('system',
'🎵 ' + escHtml(file.name) + ' 업로드 중...
'
);
try {
var form = new FormData();
form.append('file', file);
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
var data = await resp.json();
if (!resp.ok) throw new Error(data.error || '서버 오류 ' + resp.status);
var result = buildMidiPickerWidget(data, file.name);
var placeholder = document.getElementById(msgId);
if (placeholder) placeholder.outerHTML = result.html;
chatHistory.push({ role: 'widget', content: result.html });
persistActiveChat();
} catch (e) {
var pl = document.getElementById(msgId);
if (pl) pl.outerHTML = '업로드 실패: ' + escHtml(e.message) + ' ';
}
}
function appendChatBubble(role, html) {
var wrap = document.getElementById('chat-messages');
if (!wrap) return;
var div = document.createElement('div');
div.className = 'msg msg-' + role;
div.innerHTML = '' + html + '
';
wrap.appendChild(div);
wrap.scrollTop = wrap.scrollHeight;
}
function openImageZoom(src) {
var modal = document.getElementById('image-zoom-modal');
var img = document.getElementById('image-zoom-img');
if (modal && img) {
img.src = src;
modal.style.display = 'flex';
}
}
function closeImageZoom() {
var modal = document.getElementById('image-zoom-modal');
if (modal) modal.style.display = 'none';
}
// Cache of checked /api/files/ URLs: url -> true (exists) | false (not found)
var _dlLinkCache = {};
function markDownloadLinkError(btn) {
var href = btn.getAttribute ? btn.getAttribute('href') : (btn.href || '');
var filename = decodeURIComponent((href || '').split('/').pop() || '파일');
_dlLinkCache[href] = false;
var span = document.createElement('span');
span.className = 'download-btn-error';
span.title = '파일을 찾을 수 없음: ' + filename;
span.textContent = '⚠ ' + filename + ' (파일 없음)';
if (btn.parentNode) btn.parentNode.replaceChild(span, btn);
}
function validateDownloadLinks(container) {
var buttons = (container || document).querySelectorAll('a.download-btn[href]');
buttons.forEach(function(btn) {
var url = btn.getAttribute('href');
if (!url || url.indexOf('/api/files/') === -1) return;
if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; }
if (_dlLinkCache[url] === true || _dlLinkCache[url] === 'pending') return;
_dlLinkCache[url] = 'pending';
fetch(url, { method: 'HEAD', headers: authHeaders() })
.then(function(r) {
_dlLinkCache[url] = r.ok;
if (!r.ok) {
var found = (container || document).querySelector('a.download-btn[href="' + url.replace(/"/g, '\\"') + '"]');
if (found) markDownloadLinkError(found);
}
})
.catch(function() { _dlLinkCache[url] = false; });
});
}
function downloadFile(e, el) {
e.preventDefault();
e.stopPropagation();
var url = el.getAttribute('href');
if (!url) return;
// Ensure absolute URL (handle sandboxed environments where origin may be sandbox:/)
if (!url.match(/^https?:\/\//)) {
if (url.charAt(0) === '/') {
// Already an absolute path — use relative to current host
url = url;
} else {
url = new URL(url, window.location.origin).href;
}
}
var filename = decodeURIComponent(url.split('/').pop() || 'download');
fetch(url, {
headers: authHeaders()
})
.then(function(r) {
if (!r.ok) throw new Error(r.status);
return r.blob();
})
.then(function(blob) {
var blobUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
})
.catch(function(err) {
markDownloadLinkError(el);
});
}
function togglePptxPreview(fileUrl, containerId, btn) {
var container = document.getElementById(containerId);
if (!container) return;
var isVisible = container.style.display !== 'none';
if (isVisible) {
container.style.display = 'none';
btn.innerHTML = '◀ Preview';
return;
}
// Show loading state
container.style.display = 'flex';
container.innerHTML = 'Generating preview... ';
btn.innerHTML = '▲ Preview';
btn.classList.add('loading');
// Extract relative path from various URL formats:
// /api/files/Folder/file.pptx → Folder/file.pptx
// https://host/api/files/Folder/file.pptx → Folder/file.pptx
// Folder/file.pptx → Folder/file.pptx
var filePath = fileUrl;
// Handle sandboxed environments where origin may be sandbox:/
if (fileUrl.charAt(0) === '/') {
filePath = fileUrl; // Already an absolute path, use as-is
} else {
try { filePath = new URL(fileUrl, window.location.origin).pathname; } catch(e) {}
}
filePath = filePath.replace(/^\/api\/files\/?/, '');
// If the link itself is a preview URL, extract the path query parameter
var previewMatch = filePath.match(/^\/api\/pptx\/preview\?path=(.+)$/);
if (previewMatch) {
filePath = previewMatch[1];
}
// Safely decode - handle already-decoded or invalid encodings
try { filePath = decodeURIComponent(filePath); } catch(e) {}
fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), {
headers: authHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.classList.remove('loading');
if (!data.success || !data.images || !data.images.length) {
container.innerHTML = 'Preview failed: ' + escHtml(data.error || 'No slides found') + ' ';
return;
}
// img 태그는 Bearer 헤더를 보낼 수 없어서 auth 없이 요청 → user context 미설정 → 파일 못 찾음.
// fetch()로 auth 포함해 가져온 뒤 Blob URL로 교체.
Promise.all(data.images.map(function(imgUrl) {
return fetch(imgUrl, { headers: authHeaders() })
.then(function(r) { return r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function(blob) { return URL.createObjectURL(blob); })
.catch(function() { return imgUrl; });
})).then(function(srcs) {
container.innerHTML = srcs.map(function(src, i) {
return ' ';
}).join('');
});
})
.catch(function(err) {
btn.classList.remove('loading');
container.innerHTML = 'Preview error: ' + escHtml(err.message) + ' ';
});
}
function handleImageUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
input.value = '';
var formData = new FormData();
formData.append('image', file);
fetch('/api/upload/image', {
method: 'POST',
headers: authHeaders(),
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
var chatInput = document.getElementById('chat-input');
var imgRef = '';
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('');
} else { alert(data.error || '이미지 업로드 실패'); }
})
.catch(function(e) { alert('업로드 오류: ' + e.message); });
// txt / md
} else if (/\.(txt|md)$/.test(name)) {
var r1 = new FileReader();
r1.onload = function(e) {
var ext = name.split('.').pop();
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```' + ext + '\n' + e.target.result + '\n```', 'text');
};
r1.readAsText(file, 'UTF-8');
// CSV
} else if (/\.csv$/.test(name)) {
var r2 = new FileReader();
r2.onload = function(e) {
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```csv\n' + e.target.result + '\n```', 'csv');
};
r2.readAsText(file, 'UTF-8');
// Excel
} else if (/\.(xlsx|xls)$/.test(name)) {
var xlsxFile = file;
var xlsxForm = new FormData();
xlsxForm.append('file', xlsxFile);
var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: xlsxForm })
.then(function(r) { return r.json(); }).catch(function() { return {}; });
loadSheetJS(function() {
var r3 = new FileReader();
r3.onload = function(e) {
try {
var wb = XLSX.read(e.target.result, { type: 'array' });
var markdownContent = '**[파일: ' + xlsxFile.name + ']**\n\n';
var previewHtml = '';
wb.SheetNames.forEach(function(sn) {
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
markdownContent += '### ' + sn + '\n';
rows.forEach(function(row) { markdownContent += '| ' + row.map(function(c) { return String(c ?? ''); }).join(' | ') + ' |\n'; });
markdownContent += '\n';
previewHtml += '' + escHtml(sn) + '
';
previewHtml += '';
rows.slice(0, 30).forEach(function(row, ri) {
previewHtml += '';
row.forEach(function(cell) {
var tag = ri === 0 ? 'th' : 'td';
previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '' + tag + '>';
});
previewHtml += ' ';
});
if (rows.length > 30) previewHtml += '… ' + (rows.length - 30) + '행 더 ';
previewHtml += '
';
});
xlsxUploadPromise.then(function(d) {
var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml });
});
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
};
r3.readAsArrayBuffer(xlsxFile);
});
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
} else if (/\.pdf$/.test(name)) {
var pdfFile = file;
var pdfForm = new FormData();
pdfForm.append('file', pdfFile);
var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm })
.then(function(r) { return r.json(); });
var extractPromise = new Promise(function(resolve) {
loadPdfJs(function() {
var fr = new FileReader();
fr.onload = function(e) {
pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise
.then(function(pdf) {
var pages = [];
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
Promise.all(pages).then(function(ps) {
return Promise.all(ps.map(function(p, pi) {
return p.getTextContent().then(function(tc) {
return pdfPageToText(tc.items, pi + 1, pdf.numPages);
});
}));
}).then(function(texts) {
resolve({ text: texts.join('\n\n'), pages: pdf.numPages });
}).catch(function() { resolve({ text: '', pages: 0 }); });
}).catch(function() { resolve({ text: '', pages: 0 }); });
};
fr.readAsArrayBuffer(pdfFile);
});
});
Promise.all([uploadPromise, extractPromise]).then(function(res) {
var upData = res[0], extracted = res[1];
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
var fullText = (extracted.text || '').trim();
if (fullText) {
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
var txtForm = new FormData();
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm })
.then(function(r) { return r.json(); })
.then(function(d) {
var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')';
if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')';
content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
txtPreview: fullText.slice(0, 500),
txtUrl: txtUrl,
txtName: txtName,
pages: extracted.pages
});
}).catch(function() {
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n이 파일로 무엇을 도와드릴까요?';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
txtPreview: fullText.slice(0, 500),
pages: extracted.pages
});
});
} else {
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
}
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
// 기타 (docx/pptx 등)
} else {
var otherForm = new FormData();
otherForm.append('file', file);
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')');
} else { alert(data.error || '파일 업로드 실패'); }
})
.catch(function(e) { alert('업로드 오류: ' + e.message); });
}
}
function insertIntoChatInput(text) {
var el = document.getElementById('chat-input');
el.value = el.value ? el.value + '\n' + text : text;
el.focus();
el.dispatchEvent(new Event('input'));
}
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
var stagedAttachments = [];
function stageAttachment(name, url, content, type, extra) {
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
var att = { id: id, name: name, url: url, content: content, type: type };
if (extra) Object.assign(att, extra);
stagedAttachments.push(att);
renderStagedAttachments();
}
function removeStagedAttachment(id) {
stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; });
renderStagedAttachments();
}
function clearStagedAttachments() {
stagedAttachments = [];
renderStagedAttachments();
}
function renderStagedAttachments() {
var container = document.getElementById('staged-attachments');
if (!container) return;
if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; }
container.style.display = 'flex';
container.innerHTML = stagedAttachments.map(function(a) {
var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝';
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px';
// PDF 카드
if (a.type === 'pdf') {
var txtPreview = a.txtPreview ? escHtml(a.txtPreview) + (a.txtPreview.length >= 500 ? '...' : '') : '';
var pagesInfo = a.pages ? ' (' + a.pages + '페이지)' : '';
return ''
+ '
'
+ '
📄 '
+ '
' + escHtml(a.name) + ' '
+ (txtPreview ? '
txt 미리보기 ' : '')
+ (a.url ? '
PDF ' : '')
+ (a.txtUrl ? '
📝⬇ ' : '')
+ '
✕ '
+ '
'
+ (pagesInfo ? '
텍스트 추출 완료' + pagesInfo + '
' : '')
+ (txtPreview ? '
' + txtPreview + '
' : '')
+ '
'
+ '
';
}
// TXT 자동저장 카드 (미리보기 + 다운로드)
if (a.type === 'txt-auto') {
var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : '');
return ''
+ '
'
+ '
📝 '
+ '
' + escHtml(a.name) + ' '
+ '
미리보기 '
+ (a.url ? '
⬇ 저장 ' : '')
+ '
✕ '
+ '
'
+ '
' + preview + '
'
+ '
';
}
// excel 카드
if (a.type === 'excel') {
return ''
+ '
'
+ '
📊 '
+ '
' + escHtml(a.name) + ' '
+ (a.excelPreviewHtml ? '
미리보기 ' : '')
+ (a.url ? '
⬇ 저장 ' : '')
+ '
✕ '
+ '
'
+ (a.excelPreviewHtml ? '
' + a.excelPreviewHtml + '
' : '')
+ '
';
}
// 기타 카드 (text, csv)
return ''
+ '
'
+ '' + icon + ' '
+ '' + escHtml(a.name) + ' '
+ '✕ '
+ '
'
+ '
';
}).join('');
}
function toggleStagedPdfPreview(id) {
var el = document.getElementById('pdf-preview-' + id);
if (!el) return;
var att = stagedAttachments.find(function(a) { return a.id === id; });
if (el.style.display === 'none') {
el.style.display = 'block';
var iframe = el.querySelector('iframe');
if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정
} else {
el.style.display = 'none';
}
}
function toggleStagedTxtPreview(id) {
var el = document.getElementById('txt-preview-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function toggleStagedTxtPdfPreview(id) {
var el = document.getElementById('txt-pdf-preview-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
// PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
// stagedAttachments에서 해당 PDF 찾기
var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; });
if (!att) return;
// txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용)
att.txtName = txtName;
att.txtUrl = txtUrl;
// 카드 DOM에 txt 버튼 삽입
var card = document.getElementById('card-' + att.id);
if (!card) return;
var btnRow = card.querySelector('div');
if (!btnRow) return;
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block';
var txtBtn = document.createElement('a');
txtBtn.href = txtUrl;
txtBtn.download = txtName;
txtBtn.title = txtName + ' 다운로드';
txtBtn.style.cssText = btnStyle;
txtBtn.innerHTML = '📝 txt ⬇';
// ✕ 버튼 바로 앞에 삽입
var closeBtn = btnRow.lastElementChild;
btnRow.insertBefore(txtBtn, closeBtn);
}
// ── PDF 페이지 텍스트 구조화 ────────────────────────────────────────────────
function pdfPageToText(items, pageNum, totalPages) {
// filter out empty items
var valid = items.filter(function(it) { return it.str && it.str.trim() !== ''; });
if (!valid.length) return '';
// sort top→bottom (y desc), left→right (x asc)
valid.sort(function(a, b) {
var ay = a.transform[5], by = b.transform[5];
if (Math.abs(ay - by) > 2) return by - ay;
return a.transform[4] - b.transform[4];
});
// group items into lines by y-coordinate proximity
var LINE_Y_THRESH = 3;
var lines = [];
var cur = null;
valid.forEach(function(item) {
var y = item.transform[5];
var h = item.height || 10;
if (!cur || Math.abs(y - cur.y) > LINE_Y_THRESH) {
cur = { y: y, h: h, items: [item] };
lines.push(cur);
} else {
cur.items.push(item);
if (h > cur.h) cur.h = h;
}
});
// build output: join items per line; insert blank line on large y-gaps (paragraph break)
var out = [];
if (totalPages > 1) out.push('── ' + pageNum + '페이지 ──');
for (var i = 0; i < lines.length; i++) {
var lineText = lines[i].items.map(function(it) { return it.str; }).join('').replace(/\s+/g, ' ').trim();
if (!lineText) continue;
// paragraph break: gap > 1.4× current line height
if (i > 0) {
var gap = lines[i - 1].y - lines[i].y;
if (gap > lines[i - 1].h * 1.4) out.push('');
}
// fix hyphenation: merge "단어-\n다음줄" → "단어다음줄"
if (out.length && out[out.length - 1].endsWith('-')) {
out[out.length - 1] = out[out.length - 1].slice(0, -1) + lineText;
} else {
out.push(lineText);
}
}
return out.join('\n');
}
// ── SheetJS / PDF.js / x-spreadsheet 동적 로드 ──────────────────────────────────────────────
var _sheetJsLoaded = false;
function loadSheetJS(cb) {
if (_sheetJsLoaded) return cb();
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
s.onload = function() { _sheetJsLoaded = true; cb(); };
document.head.appendChild(s);
}
var _luckyLoaded = false;
function loadLuckysheet(cb) {
if (_luckyLoaded) return cb();
var cssList = [
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css',
];
cssList.forEach(function(href) {
var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
document.head.appendChild(l);
});
var scripts = [
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js',
'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js',
];
function next(i) {
if (i >= scripts.length) { _luckyLoaded = true; cb(); return; }
var s = document.createElement('script');
s.src = scripts[i]; s.onload = function() { next(i + 1); };
s.onerror = function() { next(i + 1); }; // 실패해도 진행
document.head.appendChild(s);
}
next(0);
}
var _pdfJsLoaded = false;
function loadPdfJs(cb) {
if (_pdfJsLoaded) return cb();
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
s.onload = function() {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
_pdfJsLoaded = true; cb();
};
document.head.appendChild(s);
}
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
return `${Math.floor(diff/86400000)}d ago`;
}
function openOllamaInfo() { window.open('http://localhost:11434','_blank'); }
// ---- Skills management (v2 - local skills system) ----
let skillsCache = [];
let editingSkillId = null;
let skillsTargetUser = ''; // '' = global (admin) or own username (regular user)
async function initSkillsUserSelector() {
const selector = document.getElementById('skills-user-selector');
if (!selector) return;
if (!currentUser) { selector.style.display = 'none'; return; }
if (currentUser.role !== 'admin') {
selector.style.display = 'none';
skillsTargetUser = currentUser.username || '';
return;
}
selector.style.display = 'block';
try {
const data = await api('/api/users');
const sel = document.getElementById('skills-target-user');
if (!sel || !data.users) return;
const opts = ['글로벌 기본값 '];
for (const u of data.users) {
const selected = u.username === skillsTargetUser ? ' selected' : '';
opts.push(`${escHtml(u.username)} `);
}
sel.innerHTML = opts.join('');
} catch {}
}
function onSkillsUserChange() {
const sel = document.getElementById('skills-target-user');
skillsTargetUser = sel ? sel.value : '';
loadInstalledSkills();
}
async function loadInstalledSkills() {
const el = document.getElementById('skills-list');
el.innerHTML = ' Loading...
';
try {
const effectiveUser = skillsTargetUser || currentUser?.username || null;
const url = effectiveUser ? `/api/skills?username=${encodeURIComponent(effectiveUser)}` : '/api/skills';
const data = await api(url);
const skills = data.skills || [];
skillsCache = skills;
if (skills.length === 0) {
el.innerHTML = 'No skills yet. Click "+ Create" to make one!
';
} else {
el.innerHTML = skills.map(s => {
const skillEmoji = (s.emoji && s.emoji !== '🦞' && s.emoji !== '⚠️') ? s.emoji : '🧩';
const globalBadge = s.globalOnly ? '[전체] ' : '';
return `
${escHtml(skillEmoji)}
${escHtml(s.name)}${globalBadge}
${escHtml(s.description || 'No description')}
`;
}).join('');
renderSkillEligibility(skills);
}
} catch (err) {
el.innerHTML = `Error: ${err.message}
`;
}
}
async function toggleSkill(id) {
const skillMeta = (skillsCache || []).find(s => s.id === id);
if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) {
addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.');
return;
}
// globalOnly 스킬은 per-user 모드에서 토글 불가 (서버가 전역으로 처리)
if (skillMeta?.globalOnly && skillsTargetUser) {
addProcessEntry('error', `"${skillMeta.name}" 스킬은 글로벌 전용입니다.`);
return;
}
try {
const effectiveUser = skillsTargetUser || currentUser?.username || null;
const body = effectiveUser ? JSON.stringify({ username: effectiveUser }) : undefined;
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST', body });
if (!res.success) {
addProcessEntry('error', res.error || 'Failed to toggle skill.');
return;
}
const label = res.targetUser ? ` (${res.targetUser})` : '';
addProcessEntry('info', `Skill "${res.skill.name}"${label}: ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
loadInstalledSkills();
} catch (err) {
addProcessEntry('error', `Failed to toggle skill: ${err.message}`);
}
}
async function deleteSkill(id) {
if (!confirm(`Delete skill "${id}"?`)) return;
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.success) {
addProcessEntry('info', `Skill "${id}" deleted.`);
loadInstalledSkills();
} else {
alert(`Delete failed: ${res.error}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
function openCreateSkillModal() {
editingSkillId = null;
document.getElementById('skill-modal-title').textContent = '스킬 생성';
document.getElementById('skill-modal-save-btn').textContent = '스킬 생성';
document.getElementById('skill-modal-name').value = '';
document.getElementById('skill-modal-emoji').value = '';
document.getElementById('skill-modal-desc').value = '';
document.getElementById('skill-modal-instructions').value = '';
document.getElementById('skill-modal').style.display = 'flex';
document.getElementById('skill-modal-name').focus();
}
async function editSkill(id) {
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}`);
if (!res.success) { alert('Skill not found'); return; }
const s = res.skill;
editingSkillId = id;
document.getElementById('skill-modal-title').textContent = '스킬 편집';
document.getElementById('skill-modal-save-btn').textContent = '변경 저장';
document.getElementById('skill-modal-name').value = s.name || '';
document.getElementById('skill-modal-emoji').value = s.emoji || '';
document.getElementById('skill-modal-desc').value = s.description || '';
document.getElementById('skill-modal-instructions').value = s.instructions || '';
document.getElementById('skill-modal').style.display = 'flex';
document.getElementById('skill-modal-name').focus();
} catch (err) {
alert(`Error loading skill: ${err.message}`);
}
}
function closeSkillModal() {
document.getElementById('skill-modal').style.display = 'none';
editingSkillId = null;
}
async function saveSkillFromModal() {
const name = document.getElementById('skill-modal-name').value.trim();
const rawEmoji = document.getElementById('skill-modal-emoji').value.trim();
const emoji = (rawEmoji === '🧩' || rawEmoji === '🦞' || rawEmoji === '⚠️') ? rawEmoji : '🧩';
const description = document.getElementById('skill-modal-desc').value.trim();
const instructions = document.getElementById('skill-modal-instructions').value.trim();
if (!name) { alert('Name is required'); return; }
if (!instructions) { alert('Instructions are required'); return; }
const btn = document.getElementById('skill-modal-save-btn');
btn.disabled = true;
btn.textContent = '저장 중...';
try {
let res;
if (editingSkillId) {
// Update existing
res = await api(`/api/skills/${encodeURIComponent(editingSkillId)}`, {
method: 'PUT',
body: JSON.stringify({ name, emoji, description, instructions }),
});
} else {
// Create new
res = await api('/api/skills', {
method: 'POST',
body: JSON.stringify({ name, emoji, description, instructions }),
});
}
if (res.success) {
addProcessEntry('info', `Skill "${name}" ${editingSkillId ? 'updated' : 'created'}!`);
closeSkillModal();
loadInstalledSkills();
} else {
alert(`Failed: ${res.error}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = editingSkillId ? '변경 저장' : '스킬 생성';
}
}
// ---- Stop Button ----
let currentAbortController = null;
function handleSendStop() {
if (isThinking) stopGeneration();
else sendChat();
}
function stopGeneration() {
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
}
function setButtonState(thinking) {
const sendIcon = document.getElementById('send-icon');
const stopIcon = document.getElementById('stop-icon');
const btn = document.getElementById('send-btn');
if (!btn) return;
if (thinking) {
if (sendIcon) sendIcon.style.display = 'none';
if (stopIcon) stopIcon.style.display = '';
btn.style.background = 'var(--err)';
btn.title = 'Stop generation';
} else {
if (sendIcon) sendIcon.style.display = '';
if (stopIcon) stopIcon.style.display = 'none';
btn.style.background = '';
btn.title = 'Send';
}
}
// ---- Context Pinning ----
const MAX_PINS = 3;
let contextPinMode = false;
let pinnedMessageIndices = new Set();
let confirmedPins = []; // Array of { role, content } sent with each request
function toggleContextPinMode() {
const pop = document.getElementById('context-pin-popover');
if (!pop) return;
const isOpen = pop.classList.contains('open');
// Close other popovers
document.getElementById('quick-mode-popover')?.classList.remove('open');
if (isOpen) {
cancelContextPin();
} else {
contextPinMode = true;
pop.classList.add('open');
document.body.classList.add('context-pin-active');
// Reset selection to currently confirmed pins
pinnedMessageIndices = new Set();
for (const pin of confirmedPins) {
const idx = chatHistory.findIndex(m => m.content === pin.content && m.role === pin.role);
if (idx >= 0) pinnedMessageIndices.add(idx);
}
updatePinUI();
renderChatMessages();
}
}
function cancelContextPin() {
contextPinMode = false;
pinnedMessageIndices = new Set();
document.getElementById('context-pin-popover')?.classList.remove('open');
document.body.classList.remove('context-pin-active');
renderChatMessages();
}
function confirmContextPin() {
confirmedPins = [];
for (const idx of pinnedMessageIndices) {
const msg = chatHistory[idx];
if (msg) confirmedPins.push({ role: msg.role === 'user' ? 'user' : 'assistant', content: msg.content });
}
contextPinMode = false;
document.getElementById('context-pin-popover')?.classList.remove('open');
document.body.classList.remove('context-pin-active');
updatePinBadge();
renderChatMessages();
if (confirmedPins.length > 0) {
addProcessEntry('info', `${confirmedPins.length} message(s) pinned to context.`);
} else {
addProcessEntry('info', 'Context pins cleared.');
}
}
function togglePinMessage(index) {
if (!contextPinMode) return;
if (pinnedMessageIndices.has(index)) {
pinnedMessageIndices.delete(index);
} else if (pinnedMessageIndices.size < MAX_PINS) {
pinnedMessageIndices.add(index);
}
updatePinUI();
renderChatMessages();
}
function removePinFromList(index) {
pinnedMessageIndices.delete(index);
updatePinUI();
renderChatMessages();
}
function updatePinUI() {
const countEl = document.getElementById('context-pin-count');
const listEl = document.getElementById('context-pin-list');
if (countEl) countEl.textContent = `${pinnedMessageIndices.size}/${MAX_PINS} selected`;
if (listEl) {
if (pinnedMessageIndices.size === 0) {
listEl.innerHTML = 'Click messages in chat to select them
';
} else {
listEl.innerHTML = Array.from(pinnedMessageIndices).map(idx => {
const msg = chatHistory[idx];
if (!msg) return '';
const icon = msg.role === 'user' ? '??' : '??';
const text = msg.content.slice(0, 60) + (msg.content.length > 60 ? '...' : '');
return `
${icon}
${escHtml(text)}
×
`;
}).join('');
}
}
}
function updatePinBadge() {
const label = document.getElementById('context-pin-label');
if (label) {
label.textContent = confirmedPins.length > 0 ? `컨텍스트 (${confirmedPins.length})` : '컨텍스트';
}
const btn = document.getElementById('context-pin-btn');
if (btn) {
btn.style.borderColor = confirmedPins.length > 0 ? '#86d4a8' : '';
btn.style.background = confirmedPins.length > 0 ? '#f0faf4' : '';
btn.style.color = confirmedPins.length > 0 ? '#0a6b3d' : '';
}
}
function getInitialTheme() {
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'light' || saved === 'dark') return saved;
} catch {}
return 'light';
}
function applyTheme(theme) {
const resolved = theme === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', resolved);
const toggle = document.getElementById('theme-toggle');
if (toggle) {
toggle.setAttribute('data-theme-state', resolved);
const title = resolved === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
toggle.title = title;
toggle.setAttribute('aria-label', title);
}
try { localStorage.setItem(THEME_KEY, resolved); } catch {}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
applyTheme(current === 'dark' ? 'light' : 'dark');
}
// ---- Boot ----
document.addEventListener('click', (e) => {
const pop = document.getElementById('quick-mode-popover');
const btn = document.getElementById('quick-mode-btn');
if (!pop || !btn) return;
if (!pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('open');
// Also close context pin popover
const pinPop = document.getElementById('context-pin-popover');
const pinBtn = document.getElementById('context-pin-btn');
if (pinPop && pinBtn && !pinPop.contains(e.target) && !pinBtn.contains(e.target)) {
// Don't close if in pin mode and clicking on messages
if (contextPinMode && e.target.closest && e.target.closest('.msg')) return;
}
});
// --- SCHEDULER MANAGEMENT ---------------------------------------------------
let schedules = [];
let editingScheduleId = null;
async function refreshSchedules() {
try {
const result = await api('/api/schedules');
if (result.success && Array.isArray(result.schedules)) {
schedules = result.schedules;
renderScheduleList();
}
} catch (err) {
console.error('Failed to load schedules:', err);
}
}
function renderScheduleList() {
const list = document.getElementById('schedule-list');
const count = document.getElementById('schedule-count');
if (!list) return;
if (schedules.length === 0) {
list.innerHTML = 'No schedules yet. + 새 일정 to get started.
';
if (count) count.textContent = '0 schedules';
return;
}
if (count) count.textContent = `${schedules.length} schedule${schedules.length !== 1 ? 's' : ''}`;
list.innerHTML = schedules.map(job => {
const nextRun = job.next_run ? new Date(job.next_run).toLocaleString() : 'Never';
const lastRun = job.last_run ? new Date(job.last_run).toLocaleString() : 'Never';
const statusClass = job.status === 'active' ? 'status-active' : job.status === 'paused' ? 'status-paused' : 'status-failed';
return `
${job.name || 'Untitled'}
${job.status || 'unknown'}
${job.prompt ? job.prompt.slice(0, 60) + (job.prompt.length > 60 ? '...' : '') : 'No prompt'}
Next: ${nextRun}
Last: ${lastRun}
Run Now
${job.status === 'active' ? 'Pause' : 'Resume'}
삭제
`;
}).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 = `
${col.label}
${tasks.length}
${tasks.map(t => bgtCardHTML(t, col)).join('')}
`;
board.appendChild(colEl);
if (tasks.length === 0) {
// Empty placeholder
colEl.querySelector('.bgt-col-cards').innerHTML = `No ${col.label.split(' ').slice(1).join(' ').toLowerCase()} tasks
`;
}
}
// If no tasks at all
if (bgtTasks.length === 0) {
board.innerHTML = `
작업
No background tasks yet
Ask CherryClaw to do something in the background and it'll appear here.
`;
}
}
function bgtCardHTML(t, col) {
const mins = Math.round((Date.now() - (t.lastProgressAt || t.startedAt)) / 60000);
const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.round(mins/60)}h ago`;
const stepsDone = (t.plan || []).filter(s => s.status === 'done' || s.status === 'skipped').length;
const totalSteps = (t.plan || []).length;
const pct = totalSteps > 0 ? Math.round(stepsDone / totalSteps * 100) : 0;
const currentStep = (t.plan || [])[t.currentStepIndex || 0];
const channel = t.channel === 'telegram' ? 'Telegram' : 'Web UI';
return `
${escHtml(t.title || 'Untitled Task')}
✕
${STATUS_ICON[t.status] || '?'}
${currentStep ? `
Step ${(t.currentStepIndex||0)+1}/${totalSteps}: ${escHtml(currentStep.description.slice(0,60))}${currentStep.description.length>60?'…':''}
` : ''}
${totalSteps > 0 ? `
` : ''}
${channel} ${timeAgo}
${t.status === 'needs_assistance' ? `Needs you ` : ''}
${t.status === 'paused' ? `${t.pauseReason || 'paused'} ` : ''}
`;
}
// --- Side Panel --------------------------------------------------------------
async function openBgtPanel(taskId) {
bgtOpenTaskId = taskId;
const panel = document.getElementById('bgt-panel');
if (panel) panel.style.display = 'flex';
// Clear chat input when switching tasks
const chatInput = document.getElementById('bgt-chat-input');
if (chatInput) { chatInput.value = ''; chatInput.disabled = false; chatInput.style.opacity = ''; }
await bgtRefreshOpenPanel();
// Highlight the card
renderBgTasks();
}
function closeBgtPanel() {
bgtOpenTaskId = null;
const panel = document.getElementById('bgt-panel');
if (panel) panel.style.display = 'none';
renderBgTasks();
}
async function bgtRefreshOpenPanel() {
if (!bgtOpenTaskId) return;
let task;
try {
const data = await api(`/api/bg-tasks/${bgtOpenTaskId}`);
if (data.success) task = data.task;
} catch {}
if (!task) { task = bgtTasks.find(t => t.id === bgtOpenTaskId); }
if (!task) return;
const titleEl = document.getElementById('bgt-panel-title');
if (titleEl) titleEl.textContent = task.title || '작업 상세';
const pauseBtn = document.getElementById('bgt-panel-pause');
if (pauseBtn) {
if (task.status === 'running') {
pauseBtn.textContent = '일시정지';
pauseBtn.style.display = '';
} else if (task.status === 'paused' || task.status === 'queued') {
pauseBtn.textContent = '재개';
pauseBtn.style.display = '';
} else {
pauseBtn.style.display = 'none';
}
}
const body = document.getElementById('bgt-panel-body');
if (!body) return;
const plan = task.plan || [];
const journal = task.journal || [];
const stepsDone = plan.filter(s => s.status === 'done' || s.status === 'skipped').length;
const lastTool = task.lastToolCall || '—';
const lastToolAt = task.lastToolCallAt ? new Date(task.lastToolCallAt).toLocaleTimeString() : '—';
const channel = task.channel === 'telegram' ? 'Telegram' : 'Web UI';
const startedAt = task.startedAt ? new Date(task.startedAt).toLocaleString() : '—';
const statusColor = { running:'#0d4faf', queued:'#7c4d00', paused:'#555', stalled:'#9c1a1a', needs_assistance:'#6d2d9e', complete:'#1a6e35', failed:'#9c1a1a' };
const sc = statusColor[task.status] || '#555';
// Build plan steps HTML
const stepsHTML = plan.map((step, i) => {
const icon = step.status === 'done' ? '✓' : step.status === 'failed' ? '✕' : step.status === 'skipped' ? '↷' : i === (task.currentStepIndex || 0) && task.status === 'running' ? '▶' : '○';
const isCurrent = i === (task.currentStepIndex || 0) && task.status !== 'complete';
return `
${icon}
${escHtml(step.description)}
${step.notes ? `
${escHtml(step.notes.slice(0,120))}
` : ''}
`;
}).join('');
// Build journal HTML (last 30 entries)
const recentJournal = journal.slice(-30).reverse();
const journalHTML = recentJournal.map(entry => {
const time = new Date(entry.t).toLocaleTimeString();
const typeColor = { tool_call:'#0d4faf', tool_result:'#1a6e35', error:'#9c1a1a', plan_mutation:'#6d2d9e', status_push:'#555', pause:'#7c4d00', resume:'#7c4d00', advisor_decision:'#555', heartbeat:'var(--brand)' };
const tc = typeColor[entry.type] || '#888';
return `
${time}
${entry.type}
${escHtml(entry.content)}
`;
}).join('');
// Build assistance block outside template literal to avoid backtick nesting issues
let assistanceHTML = '';
const taskStatusNorm = String(task.status || '').trim().toLowerCase();
console.log('[BGT panel] task.status =', JSON.stringify(task.status), 'norm =', taskStatusNorm);
if (taskStatusNorm === 'needs_assistance' || taskStatusNorm === 'paused' || taskStatusNorm === 'stalled' || taskStatusNorm === 'failed') {
const lastPause = [...journal].reverse().find(e => e.type === 'pause' || e.type === 'error');
const lastStatusPush = [...journal].reverse().find(e => e.type === 'status_push' && e.content);
const pauseMsg = escHtml((lastPause?.content || lastStatusPush?.content || 'Task paused and waiting for input.').replace(/^Task paused for assistance:\s*/i, ''));
const pauseDetail = lastPause?.detail ? escHtml(lastPause.detail.slice(0, 300)) : '';
const tid = escHtml(task.id);
assistanceHTML = ''
+ '
⚠️ Task needs your input
'
+ '
' + pauseMsg + '
'
+ (pauseDetail ? '
' + pauseDetail + '
' : '')
+ '
'
+ ''
+ '
💬 Reply to agent
'
+ '
'
+ ''
+ '전송 '
+ '
';
}
body.innerHTML = `
${STATUS_ICON[task.status]} ${task.status.replace(/_/g,' ')}
${channel}
Started ${startedAt}
${task.finalSummary ? `
✓ Summary
${escHtml(task.finalSummary)}
` : ''}
${assistanceHTML}
Progress
${stepsDone}/${plan.length}
steps done
Last Tool
${escHtml(lastTool.slice(0,30))}
${lastToolAt}
${plan.length > 0 ? `
Execution Plan
${stepsHTML}
` : ''}
Process Log (latest first)
${journalHTML || '
No entries yet.
'}
${(task.status === 'complete' || task.status === 'failed') ? `
Remove ` : ''}
`;
// Wire up the reply send button via JS (avoids inline onclick escaping issues)
const sendBtn = document.getElementById('task-reply-send');
if (sendBtn) {
sendBtn.addEventListener('click', () => bgtSendReply(sendBtn.dataset.taskid));
}
// Also allow Enter (without Shift) in the textarea to send
const replyInput = document.getElementById('task-reply-input');
if (replyInput) {
replyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
bgtSendReply(sendBtn ? sendBtn.dataset.taskid : bgtOpenTaskId);
}
});
}
}
// --- Actions -----------------------------------------------------------------
async function bgtPauseResume() {
if (!bgtOpenTaskId) return;
const task = bgtTasks.find(t => t.id === bgtOpenTaskId);
if (!task) return;
if (task.status === 'running') {
// Pause
try { await api(`/api/bg-tasks/${bgtOpenTaskId}/pause`, { method: 'POST' }); } catch {}
} else if (task.status === 'paused' || task.status === 'queued') {
// Resume
try { await api(`/api/bg-tasks/${bgtOpenTaskId}/resume`, { method: 'POST' }); } catch {}
}
await refreshBgTasks();
await bgtRefreshOpenPanel();
}
async function bgtSendReply(taskId) {
const input = document.getElementById('task-reply-input');
if (!input) return;
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
try {
const result = await api(`/api/bg-tasks/${taskId}/message`, { method: 'POST', body: JSON.stringify({ message }) });
if (result.success) {
// Refresh the panel after a short delay to show the journal update
setTimeout(() => bgtRefreshOpenPanel(), 800);
} else {
alert('Failed to send: ' + (result.error || 'unknown error'));
}
} catch (e) {
alert('Error sending message: ' + e.message);
} finally {
input.disabled = false;
input.focus();
}
}
async function bgtChatSend() {
const input = document.getElementById('bgt-chat-input');
if (!input || !bgtOpenTaskId) return;
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
input.style.opacity = '0.5';
try {
const result = await api(`/api/bg-tasks/${bgtOpenTaskId}/message`, { method: 'POST', body: JSON.stringify({ message }) });
if (result.success) {
// Show optimistic sent indicator in journal area
const body = document.getElementById('bgt-panel-body');
if (body) {
const sentDiv = document.createElement('div');
sentDiv.style.cssText = 'background:var(--brand);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;align-self:flex-end;max-width:80%;word-break:break-word;opacity:0.85';
sentDiv.textContent = '\u2192 ' + message;
body.appendChild(sentDiv);
body.scrollTop = body.scrollHeight;
}
// Refresh panel after short delay to show journal update
setTimeout(() => bgtRefreshOpenPanel(), 1000);
} else {
alert('Failed to send: ' + (result.error || 'unknown error'));
}
} catch (e) {
alert('Error: ' + e.message);
} finally {
input.disabled = false;
input.style.opacity = '';
input.focus();
}
}
async function bgtDeleteTask(taskId, status) {
const isActive = status && status !== 'complete' && status !== 'failed';
const prompt = isActive
? 'This task is still active. Remove it from the board anyway?'
: 'Remove this task from the board?';
if (!confirm(prompt)) return;
try { await api(`/api/bg-tasks/${taskId}`, { method: 'DELETE' }); } catch {}
if (bgtOpenTaskId === taskId) closeBgtPanel();
await refreshBgTasks();
}
// --- Toast notification -------------------------------------------------------
function bgtToast(title, body) {
const toast = document.createElement('div');
toast.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:9999;background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 16px;min-width:240px;max-width:360px;box-shadow:0 4px 24px rgba(0,0,0,0.12);font-family:var(--font)`;
toast.innerHTML = `${escHtml(title)}
${body ? `${escHtml(body)}
` : ''}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 6000);
}
// --- Integrations Tab ---------------------------------------------------------
let _integTab = 'webhooks';
function setIntegTab(tab) {
_integTab = tab;
['webhooks', 'mcp'].forEach(t => {
const btn = document.getElementById('itab-' + t);
const panel = document.getElementById('itab-panel-' + t);
if (btn) {
btn.style.background = t === tab ? '#eaf2ff' : '#fff';
btn.style.color = t === tab ? '#0d4faf' : 'var(--muted)';
btn.style.borderColor = t === tab ? '#bdd3f6' : 'var(--line)';
}
if (panel) panel.style.display = t === tab ? 'block' : 'none';
});
}
async function loadIntegrationsTab() {
setIntegTab(_integTab);
await loadWebhookSettings();
await loadMCPServers();
}
// --- Webhooks -----------------------------------------------------------------
async function loadWebhookSettings() {
try {
const data = await api('/api/settings/hooks');
const h = data.hooks || {};
const cb = document.getElementById('wh-enabled');
const inp = document.getElementById('wh-token');
const pathInp = document.getElementById('wh-path');
if (cb) cb.checked = h.enabled === true;
if (inp) inp.value = h.tokenSet ? '••••••••' : '';
if (pathInp) pathInp.value = h.path || '/hooks';
updateWebhookStatus(h.enabled, h.tokenSet);
updateWebhookUrlDisplay(h.enabled, h.tokenSet, h.path || '/hooks');
} catch(e) {
console.warn('loadWebhookSettings:', e);
}
}
function updateWebhookStatus(enabled, tokenSet) {
const dot = document.getElementById('wh-status-dot');
const txt = document.getElementById('wh-status-text');
if (!dot || !txt) return;
if (enabled && tokenSet) {
dot.style.background = '#22c55e';
txt.textContent = '활성 — 엔드포인트 실행 중';
txt.style.color = '#166534';
} else if (!enabled) {
dot.style.background = '#ccc';
txt.textContent = '비활성';
txt.style.color = 'var(--muted)';
} else {
dot.style.background = '#f59e0b';
txt.textContent = '활성화됨 — 토큰 미설정';
txt.style.color = '#92400e';
}
}
function updateWebhookUrlDisplay(enabled, tokenSet, hookPath) {
const el = document.getElementById('wh-url-display');
if (!el) return;
if (enabled && tokenSet) {
const origin = window.location.origin || 'http://localhost:18789';
el.style.display = 'block';
el.textContent = origin + hookPath + '/agent';
} else {
el.style.display = 'none';
}
}
function generateWebhookToken() {
const arr = new Uint8Array(24);
crypto.getRandomValues(arr);
const token = Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join('');
const inp = document.getElementById('wh-token');
if (inp) { inp.value = token; inp.type = 'text'; setTimeout(() => { inp.type = 'password'; }, 4000); }
}
async function saveWebhookSettings() {
const enabled = document.getElementById('wh-enabled')?.checked ?? false;
const rawToken = document.getElementById('wh-token')?.value?.trim() || '';
const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks';
try {
const r = await api('/api/settings/hooks', {
method: 'POST',
body: JSON.stringify({ enabled, token: rawToken, path: hookPath }),
});
if (r.success) {
await loadWebhookSettings();
showIntegMsg('? Webhook settings saved — restart gateway to apply', '#166534', '#f0fdf4');
} else {
showIntegMsg('Error: ' + (r.error || 'Unknown error'), '#991b1b', '#fef2f2');
}
} catch(e) {
showIntegMsg('Save failed: ' + e.message, '#991b1b', '#fef2f2');
}
}
async function testWebhookEndpoint() {
try {
const r = await api('/api/settings/hooks/test', { method: 'POST' });
if (r.success) {
showIntegMsg('? ' + (r.message || 'Endpoint is active'), '#166534', '#f0fdf4');
} else {
showIntegMsg('? ' + (r.error || 'Test failed'), '#991b1b', '#fef2f2');
}
} catch(e) {
showIntegMsg('Test failed: ' + e.message, '#991b1b', '#fef2f2');
}
}
function copyWebhookCurl() {
const origin = window.location.origin || 'http://localhost:18789';
const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks';
const curl = `curl -X POST ${origin}${hookPath}/agent \\
-H "x-cherryclaw-token: YOUR_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"message": "Hello from webhook", "name": "MyApp", "deliver": true}'`;
navigator.clipboard?.writeText(curl).then(() => {
showIntegMsg('cURL example copied to clipboard', '#1e40af', '#eff6ff');
}).catch(() => {
showIntegMsg('Could not copy — check browser permissions', '#92400e', '#fffbeb');
});
}
// --- MCP ---------------------------------------------------------------------
let _mcpEditingId = null;
const MCP_PRESETS = {
filesystem: { name: 'Filesystem', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-filesystem\nC:\\Users', env: '', description: 'Read/write local files' },
github: { name: 'GitHub', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-github', env: 'GITHUB_PERSONAL_ACCESS_TOKEN=', description: 'Repos, PRs, issues' },
postgres: { name: 'PostgreSQL', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-postgres\npostgresql://localhost/mydb', env: '', description: 'Query PostgreSQL' },
sqlite: { name: 'SQLite', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-sqlite\n--db-path\nC:\\path\\to\\db.sqlite', env: '', description: 'Local database' },
brave: { name: 'Brave Search', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-brave-search', env: 'BRAVE_API_KEY=', description: 'Web search' },
memory: { name: 'Memory', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-memory', env: '', description: 'Persistent knowledge store' },
};
async function loadMCPServers() {
const el = document.getElementById('mcp-server-list');
if (!el) return;
try {
const data = await api('/api/mcp/servers');
const servers = data.servers || [];
if (servers.length === 0) {
el.innerHTML = 'No MCP servers configured yet. Add one above or click a preset on the right.
';
return;
}
el.innerHTML = servers.map(s => {
const statusColor = s.status === 'connected' ? '#22c55e' : s.status === 'error' ? '#ef4444' : s.status === 'connecting' ? '#f59e0b' : '#9ca3af';
const statusLabel = s.status === 'connected' ? `Connected · ${s.toolCount} tool${s.toolCount !== 1 ? 's' : ''}` : s.status === 'error' ? 'Error' : s.status === 'connecting' ? 'Connecting…' : 'Disconnected';
const toolTip = s.toolNames?.length ? s.toolNames.join(', ') : '';
return `
${escHtml(s.name)} (${escHtml(s.id)})
${statusLabel}${s.error ? ` — ${escHtml(s.error.slice(0,60))}` : ''}
${toolTip ? `
${escHtml(toolTip.slice(0,80))}${toolTip.length>80?'…':''}
` : ''}
${s.status === 'connected'
? `Disconnect `
: `Connect `
}
Edit
?
`;
}).join('');
} catch(e) {
el.innerHTML = `Failed to load: ${escHtml(e.message)}
`;
}
}
function showMCPAddForm(editData) {
_mcpEditingId = editData?.id || null;
document.getElementById('mcp-add-form').style.display = 'block';
document.getElementById('mcp-form-title').textContent = _mcpEditingId ? 'MCP 서버 편집' : 'MCP 서버 추가';
document.getElementById('mcp-form-msg').textContent = '';
if (editData) {
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
v('mcp-f-id', editData.id); v('mcp-f-name', editData.name);
document.getElementById('mcp-f-transport').value = editData.transport || 'stdio';
onMCPTransportChange();
v('mcp-f-command', editData.command || '');
v('mcp-f-args', (editData.args || []).join('\n'));
v('mcp-f-env', Object.entries(editData.env || {}).map(([k,v]) => `${k}=${v}`).join('\n'));
v('mcp-f-url', editData.url || '');
v('mcp-f-headers', Object.entries(editData.headers || {}).map(([k,v]) => `${k}: ${v}`).join('\n'));
const cb = document.getElementById('mcp-f-enabled');
if (cb) cb.checked = editData.enabled !== false;
} else {
['mcp-f-id','mcp-f-name','mcp-f-command','mcp-f-args','mcp-f-env','mcp-f-url','mcp-f-headers'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
document.getElementById('mcp-f-transport').value = 'stdio';
onMCPTransportChange();
const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = true;
}
document.getElementById('mcp-f-id').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function hideMCPAddForm() {
document.getElementById('mcp-add-form').style.display = 'none';
_mcpEditingId = null;
}
function onMCPTransportChange() {
const t = document.getElementById('mcp-f-transport')?.value;
document.getElementById('mcp-stdio-fields').style.display = t === 'stdio' ? 'block' : 'none';
document.getElementById('mcp-sse-fields').style.display = t === 'sse' ? 'block' : 'none';
}
function prefillMCPServer(presetKey) {
const p = MCP_PRESETS[presetKey];
if (!p) return;
showMCPAddForm();
const v = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
v('mcp-f-id', presetKey);
v('mcp-f-name', p.name);
document.getElementById('mcp-f-transport').value = p.transport;
onMCPTransportChange();
v('mcp-f-command', p.command || '');
v('mcp-f-args', p.args || '');
v('mcp-f-env', p.env || '');
v('mcp-f-url', p.url || '');
setIntegTab('mcp');
}
async function saveMCPServer() {
const id = document.getElementById('mcp-f-id')?.value?.trim();
const name = document.getElementById('mcp-f-name')?.value?.trim();
const transport = document.getElementById('mcp-f-transport')?.value || 'stdio';
const enabled = document.getElementById('mcp-f-enabled')?.checked ?? true;
const msgEl = document.getElementById('mcp-form-msg');
if (!id || !name) { if (msgEl) msgEl.textContent = 'ID와 이름은 필수입니다.'; return; }
const cfg = { id, name, transport, enabled };
if (transport === 'stdio') {
cfg.command = document.getElementById('mcp-f-command')?.value?.trim() || '';
const argsRaw = document.getElementById('mcp-f-args')?.value || '';
cfg.args = argsRaw.split('\n').map(s => s.trim()).filter(Boolean);
const envRaw = document.getElementById('mcp-f-env')?.value || '';
cfg.env = {};
envRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
const eq = line.indexOf('='); if (eq > 0) cfg.env[line.slice(0,eq).trim()] = line.slice(eq+1);
});
} else {
cfg.url = document.getElementById('mcp-f-url')?.value?.trim() || '';
const headersRaw = document.getElementById('mcp-f-headers')?.value || '';
cfg.headers = {};
headersRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
const colon = line.indexOf(':'); if (colon > 0) cfg.headers[line.slice(0,colon).trim()] = line.slice(colon+1).trim();
});
}
try {
const r = await api('/api/mcp/servers', { method: 'POST', body: JSON.stringify(cfg) });
if (r.success) {
if (msgEl) msgEl.textContent = '? Saved';
setTimeout(() => hideMCPAddForm(), 600);
await loadMCPServers();
// Auto-connect if enabled
if (enabled) connectMCPServer(id);
} else {
if (msgEl) msgEl.textContent = '오류: ' + (r.error || '알 수 없음');
}
} catch(e) {
if (msgEl) msgEl.textContent = '저장 실패: ' + e.message;
}
}
async function editMCPServer(id) {
try {
const data = await api('/api/mcp/servers');
const server = (data.servers || []).find(s => s.id === id);
if (server) showMCPAddForm(server);
} catch(e) { console.warn('editMCPServer:', e); }
}
async function deleteMCPServer(id) {
if (!confirm(`Delete MCP server "${id}"?`)) return;
try {
await api(`/api/mcp/servers/${id}`, { method: 'DELETE' });
await loadMCPServers();
} catch(e) { showIntegMsg('Delete failed: ' + e.message, '#991b1b', '#fef2f2'); }
}
async function connectMCPServer(id) {
showIntegMsg('Connecting to ' + id + '…', '#1e40af', '#eff6ff');
try {
const r = await api(`/api/mcp/servers/${id}/connect`, { method: 'POST' });
if (r.success) {
showIntegMsg(`? Connected — ${(r.tools||[]).length} tool(s) available`, '#166534', '#f0fdf4');
} else {
showIntegMsg('? Connection failed: ' + (r.error || 'Unknown'), '#991b1b', '#fef2f2');
}
await loadMCPServers();
} catch(e) {
showIntegMsg('Connect error: ' + e.message, '#991b1b', '#fef2f2');
await loadMCPServers();
}
}
async function disconnectMCPServer(id) {
try {
await api(`/api/mcp/servers/${id}/disconnect`, { method: 'POST' });
await loadMCPServers();
showIntegMsg('Disconnected from ' + id, '#475569', '#f8fafc');
} catch(e) { showIntegMsg('Disconnect error: ' + e.message, '#991b1b', '#fef2f2'); }
}
// --- Shared helper ------------------------------------------------------------
function showIntegMsg(msg, color, bg) {
let el = document.getElementById('integ-toast');
if (!el) {
el = document.createElement('div');
el.id = 'integ-toast';
el.style.cssText = 'position:fixed;bottom:80px;right:24px;z-index:9999;padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,0.12);transition:opacity .3s';
document.body.appendChild(el);
}
el.style.background = bg || '#f0fdf4';
el.style.color = color || '#166534';
el.style.border = `1px solid ${color || '#166534'}33`;
el.style.opacity = '1';
el.textContent = msg;
clearTimeout(el._timeout);
el._timeout = setTimeout(() => { el.style.opacity = '0'; }, 3500);
}
// ───────────────────────────────────────────────────────────────────────────
// ERROR RESPONSE SYSTEM — Full JavaScript Implementation
// Handles: show/hide panel, option selection, input rendering,
// credential entry, form submission, WS event binding
// ───────────────────────────────────────────────────────────────────────────
// State
let _errTaskId = null;
let _errTemplate = null;
let _errSelectedAction = null;
let _errCategory = null;
let _errBackdrop = null;
function _getOrCreateErrorBackdrop() {
if (_errBackdrop) return _errBackdrop;
_errBackdrop = document.createElement('div');
_errBackdrop.id = 'error-response-backdrop';
_errBackdrop.style.cssText = 'position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9997;display:none';
_errBackdrop.addEventListener('click', closeErrorResponse);
document.body.appendChild(_errBackdrop);
return _errBackdrop;
}
function showErrorResponsePanel(taskId, category, errorMessage, errorDetail, template) {
_errTaskId = taskId;
_errTemplate = template;
_errSelectedAction = null;
_errCategory = category;
// Title & description
const titleEl = document.getElementById('error-response-title');
const descEl = document.getElementById('error-response-desc');
if (titleEl) titleEl.textContent = template?.title || ('\u26a0\ufe0f ' + (category || 'Error').toUpperCase());
if (descEl) descEl.textContent = template?.description || errorMessage || 'The task needs your help to continue.';
// Render options
const optionsEl = document.getElementById('error-response-options');
if (optionsEl) {
optionsEl.innerHTML = '';
const options = template?.options || [
{ id: 'retry_now', label: '\u27f3 Retry Now', icon: '\u27f3' },
{ id: 'skip', label: '\u229a Skip Step', icon: '\u229a' },
{ id: 'cancel', label: '\u2715 Cancel Task', icon: '\u2715', danger: true },
];
options.forEach(opt => {
const btn = document.createElement('button');
btn.className = 'error-option-btn' + (opt.danger ? ' danger' : '');
btn.dataset.actionId = opt.id;
btn.innerHTML = `${escHtml(opt.icon || '')} ${escHtml(opt.label)} ${opt.description ? `${escHtml(opt.description)} ` : ''}`;
btn.addEventListener('click', () => selectErrorOption(opt));
optionsEl.appendChild(btn);
});
}
// Clear inputs
const inputsEl = document.getElementById('error-response-inputs');
if (inputsEl) {
inputsEl.innerHTML = '';
inputsEl.classList.remove('visible');
}
// Reset submit button
const submitBtn = document.getElementById('error-response-submit-btn');
if (submitBtn) submitBtn.disabled = true;
// Show backdrop + panel
const backdrop = _getOrCreateErrorBackdrop();
backdrop.style.display = 'block';
const panel = document.getElementById('error-response-panel');
if (panel) panel.classList.add('visible');
// Notification toast
bgtToast('\u26a0\ufe0f Task needs help', (template?.title || category || 'Error') + ' — see panel');
}
function selectErrorOption(opt) {
_errSelectedAction = opt.id;
// Highlight selected
document.querySelectorAll('.error-option-btn').forEach(b => b.classList.remove('selected'));
const btn = document.querySelector(`.error-option-btn[data-action-id="${opt.id}"]`);
if (btn) btn.classList.add('selected');
// Show input fields if this option requires them
const inputsEl = document.getElementById('error-response-inputs');
if (inputsEl) {
inputsEl.innerHTML = '';
inputsEl.classList.remove('visible');
const triggerInputs = opt.triggerInputs || [];
const allFields = _errTemplate?.requiredInputs || [];
const fieldsToShow = triggerInputs.length > 0
? allFields.filter(f => triggerInputs.includes(f.id))
: [];
if (fieldsToShow.length > 0) {
fieldsToShow.forEach(field => {
const wrap = document.createElement('div');
wrap.className = 'input-field';
const label = document.createElement('label');
label.textContent = field.label;
label.setAttribute('for', 'err-input-' + field.id);
const input = document.createElement('input');
input.id = 'err-input-' + field.id;
input.type = field.type || 'text';
input.placeholder = field.placeholder || '';
input.dataset.fieldId = field.id;
if (field.validation === 'digits_only') {
input.inputMode = 'numeric';
input.pattern = '[0-9]*';
input.maxLength = 8;
}
wrap.appendChild(label);
wrap.appendChild(input);
inputsEl.appendChild(wrap);
});
inputsEl.classList.add('visible');
// Focus first input
const firstInput = inputsEl.querySelector('input');
if (firstInput) setTimeout(() => firstInput.focus(), 80);
}
}
// Enable submit unless it's cancel (which submits immediately)
const submitBtn = document.getElementById('error-response-submit-btn');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = opt.danger ? '작업 취소' : '계속';
}
}
function closeErrorResponse() {
_errTaskId = null;
_errTemplate = null;
_errSelectedAction = null;
_errCategory = null;
const backdrop = document.getElementById('error-response-backdrop');
if (backdrop) backdrop.style.display = 'none';
const panel = document.getElementById('error-response-panel');
if (panel) panel.classList.remove('visible');
}
async function submitErrorResponse() {
if (!_errTaskId || !_errSelectedAction) return;
const action = _errSelectedAction;
const category = _errCategory;
// Collect input values
const inputs = {};
document.querySelectorAll('#error-response-inputs input[data-field-id]').forEach(el => {
if (el.value.trim()) inputs[el.dataset.fieldId] = el.value.trim();
});
// Validate required fields for credential action
if (action === 'credentials') {
if (!inputs.email) {
const emailEl = document.getElementById('err-input-email');
if (emailEl) { emailEl.style.borderColor = 'var(--err)'; emailEl.focus(); }
return;
}
if (!inputs.password) {
const pwEl = document.getElementById('err-input-password');
if (pwEl) { pwEl.style.borderColor = 'var(--err)'; pwEl.focus(); }
return;
}
}
if (action === 'verification_code') {
if (!inputs.code) {
const codeEl = document.getElementById('err-input-code');
if (codeEl) { codeEl.style.borderColor = 'var(--err)'; codeEl.focus(); }
return;
}
}
// Disable submit to prevent double-send
const submitBtn = document.getElementById('error-response-submit-btn');
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '전송 중…'; }
try {
const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action, category, inputs }),
});
const data = await resp.json();
if (data.success) {
closeErrorResponse();
if (data.resumed) {
bgtToast('\u2705 Task resuming', 'Agent is continuing with your response');
// Refresh the kanban board to show updated status
if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 800);
} else {
bgtToast('Task cancelled', 'Task has been stopped');
if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 400);
}
} else {
bgtToast('\u274c Error', data.error || 'Failed to submit response');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; }
}
} catch (err) {
console.error('[ErrorResponse] Submit failed:', err);
bgtToast('\u274c Network error', 'Could not submit response');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; }
}
}
// Hook into the existing WebSocket message handler
// We extend the onmessage handler after the page loads
(function patchWebSocketForErrorResponse() {
function applyPatch() {
// Find the ws variable — it’s declared in a closure, so we intercept via MutationObserver
// Alternative: override addEventListener on WebSocket prototype to intercept messages
const _origOnMessage = WebSocket.prototype.__defineGetter__
? null
: null; // not needed — use event listener approach below
// We hook into the document-level custom event that server-v2’s WS handler
// already dispatches. If that’s not available, we’ll patch the WS directly.
document.addEventListener('ws:message', handleErrorResponseWsEvent);
// Also directly patch any open WebSocket connections by overriding WebSocket prototype
const _OrigWS = window.WebSocket;
window.WebSocket = function(...args) {
const ws = new _OrigWS(...args);
const _origAddEventListener = ws.addEventListener.bind(ws);
ws.addEventListener = function(type, listener, ...rest) {
if (type === 'message') {
const wrappedListener = function(e) {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'task_error_requires_response') {
handleErrorResponseWsEvent({ detail: msg });
}
} catch {}
return listener.call(this, e);
};
return _origAddEventListener('message', wrappedListener, ...rest);
}
return _origAddEventListener(type, listener, ...rest);
};
// Also patch onmessage setter
let _onmsgHandler = null;
Object.defineProperty(ws, 'onmessage', {
get: () => _onmsgHandler,
set: (fn) => {
_onmsgHandler = fn;
_origAddEventListener('message', (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'task_error_requires_response') {
handleErrorResponseWsEvent({ detail: msg });
}
} catch {}
if (_onmsgHandler) _onmsgHandler.call(ws, e);
});
},
configurable: true,
});
return ws;
};
// Copy static properties
Object.assign(window.WebSocket, _OrigWS);
window.WebSocket.prototype = _OrigWS.prototype;
window.WebSocket.CONNECTING = _OrigWS.CONNECTING;
window.WebSocket.OPEN = _OrigWS.OPEN;
window.WebSocket.CLOSING = _OrigWS.CLOSING;
window.WebSocket.CLOSED = _OrigWS.CLOSED;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyPatch);
} else {
applyPatch();
}
})();
// ── Mobile tab navigation ───────────────────────────────────────────────────
function mobTab(tab) {
const body = document.body;
if (tab === 'menu') {
const isOpen = body.classList.contains('mob-menu');
if (isOpen) {
mobCloseMenu();
} else {
body.classList.add('mob-menu');
}
// Keep chat/ctx tab highlight unchanged; toggle menu highlight
document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen);
return;
}
mobCloseMenu();
['chat', 'ctx', 'menu'].forEach(t =>
document.getElementById('mob-tab-' + t)?.classList.toggle('active', t === tab)
);
if (tab === 'ctx') {
body.classList.add('mob-ctx');
} else {
body.classList.remove('mob-ctx');
}
}
function mobCloseMenu() {
document.body.classList.remove('mob-menu');
document.getElementById('mob-backdrop').style.display = 'none';
document.getElementById('mob-tab-menu')?.classList.remove('active');
}
// ── Right-panel collapse ────────────────────────────────────────────────────
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
function applyRightPanelCollapsed(collapsed) {
document.body.classList.toggle('right-collapsed', !!collapsed);
// Only override on the true 3-column desktop layout. At ≤1200px the tablet/mobile
// media queries own the grid (sidebar hidden), so inline 3-column styles here would
// fight them — e.g. restoring a wide saved width and letting the context panel eat
// most of the screen. Clear inline styles below that width so CSS takes over.
const isDesktop = window.matchMedia('(min-width: 1201px)').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);
})();
// Re-evaluate on viewport change (e.g. tablet rotation crossing the 1200px boundary)
// so inline desktop grid styles are cleared/applied to match the active CSS layout.
window.addEventListener('resize', () => {
const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
applyRightPanelCollapsed(stored === null ? true : stored === '1');
});
// ── 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 — desktop only. Below 1201px the tablet/mobile CSS owns the
// grid, so applying a saved (often wide) width here would override it.
const isDesktop = () => window.matchMedia('(min-width: 1201px)').matches;
if (isDesktop() && !document.body.classList.contains('right-collapsed')) {
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
}
function setup() {
const handle = document.getElementById('right-panel-resize-handle');
const panel = document.getElementById('right-panel');
if (!handle || !panel) return;
handle.addEventListener('mouseenter', () => { handle.style.background = 'var(--brand, #1668e3)'; handle.style.opacity = '0.35'; });
handle.addEventListener('mouseleave', () => { handle.style.background = 'transparent'; handle.style.opacity = '1'; });
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = panel.getBoundingClientRect().width;
// Prevent iframes from swallowing events during drag
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
document.body.style.userSelect = 'none';
handle.style.background = 'var(--brand, #1668e3)';
handle.style.opacity = '0.6';
function onMove(ev) {
const newW = clamp(startW + (startX - ev.clientX));
applyWidth(newW);
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
document.body.style.userSelect = '';
handle.style.background = 'transparent';
handle.style.opacity = '1';
const finalW = panel.getBoundingClientRect().width;
localStorage.setItem(STORE_KEY, Math.round(finalW));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup);
else setup();
})();
function handleErrorResponseWsEvent(e) {
const msg = e.detail || e;
if (!msg || msg.type !== 'task_error_requires_response') return;
showErrorResponsePanel(
msg.taskId,
msg.errorCategory,
msg.errorMessage,
msg.errorDetail,
msg.template,
);
}
// ── Code editor stubs (editor moved to code.html) ────────────────────────
function v2LiveCodeUpdate() {}
function codeEditorWriteFile() {}
// ── Terminal infrastructure (PTY over WebSocket) ─────────────────────────
let termWs = null;
let termWsConnected = false;
function termWsConnect() {
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url = `${proto}//${location.host}/ws`;
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
if (token) url += `?token=${encodeURIComponent(token)}`;
try { termWs = new WebSocket(url); } catch { return; }
termWs.onopen = () => {
termWsConnected = true;
for (const [shellId, inst] of xtermInstances) {
if (!inst.ptyReady) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); }
}
};
termWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write(msg.data); }
else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); } }
else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); }
};
termWs.onclose = () => {
termWsConnected = false; termWs = null;
for (const [,i] of xtermInstances) i.ptyReady = false;
setTimeout(termWsConnect, 3000);
};
termWs.onerror = () => { termWsConnected = false; };
}
function termWsSend(msg) {
if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; }
return false;
}
const xtermInstances = new Map();
function createXtermShell(container, shellId) {
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
const sub = document.createElement('div');
sub.id = 'xterm-' + id;
sub.style.cssText = 'position:absolute;inset:0';
container.appendChild(sub);
const term = new Terminal({
theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e',
selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C',
yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC',
brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' },
fontFamily: "'IBM Plex Mono','Courier New',monospace",
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
});
const fitAddon = new FitAddon.FitAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
term.open(sub); fitAddon.fit();
const inst = { term, fitAddon, ptyReady: false };
xtermInstances.set(id, inst);
term.onData(data => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data }); });
term.onResize(({ cols, rows }) => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); });
if (termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); }
return { id, term, fitAddon, inst };
}
// Init WebSocket connection on load
fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect);
// ── Chat terminal (for chat view) ─────────────────────────────────────────
let chatTermShells = [];
let activeChatTermShellId = null;
let chatTermOpen = false;
let chatTermHeight = 280;
function toggleChatTerminal() {
const panel = document.getElementById('chat-terminal-panel');
const btn = document.getElementById('chat-term-btn');
if (!panel) return;
chatTermOpen = !chatTermOpen;
if (chatTermOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
if (!chatTermShells.length) { addChatTermShell(); }
else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
}
function renderChatShellTabs() {
const bar = document.getElementById('chat-shell-tabs'); if (!bar) return;
bar.innerHTML = chatTermShells.map(s => {
const a = s.id === activeChatTermShellId;
return `${escHtml(s.name)} ${chatTermShells.length>1?`
× `:''}
`;
}).join('');
}
function addChatTermShell() {
const id = 'ct_' + Math.random().toString(36).slice(2,8);
chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
switchChatTermShell(id);
}
function switchChatTermShell(id) {
const cur = chatTermShells.find(s => s.id === activeChatTermShellId);
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
activeChatTermShellId = id;
const shell = chatTermShells.find(s => s.id === id); if (!shell) return;
if (shell.term) {
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
} else {
const container = document.getElementById('chat-term-container');
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
}
renderChatShellTabs();
}
function closeChatTermShell(id, ev) {
if (ev) ev.stopPropagation();
const idx = chatTermShells.findIndex(s => s.id === id);
if (idx === -1 || chatTermShells.length <= 1) return;
termWsSend({ type:'pty_kill', shellId:id });
const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose();
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
chatTermShells.splice(idx, 1);
if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id);
else renderChatShellTabs();
}
document.addEventListener('DOMContentLoaded', function() {
const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return;
let startY=0, startH=0;
handle.addEventListener('mousedown', function(e) {
startY=e.clientY; startH=chatTermHeight;
const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault();
});
});
// ── PPTX Workflow Sidebar ─────────────────────────────────────────────────────
let _pptxImages = []; // {url, thumb, label}
let _pptxOutline = null; // parsed pptx-outline JSON
let _pptxOutlineEdits = {}; // {index: {title, bullets}}
function setPptxSbTab(tab) {
['setup','images','outline'].forEach(t => {
document.getElementById('psb-tab-' + t).classList.toggle('active', t === tab);
const el = document.getElementById('psb-' + t);
if (el) el.style.display = t === tab ? (t === 'outline' ? 'flex' : (t === 'images' ? 'block' : 'flex')) : 'none';
});
}
function pptxSetStatus(msg) {
const el = document.getElementById('psb-status');
if (el) el.textContent = msg;
}
function pptxStartWorkflow() {
const topic = (document.getElementById('psb-topic').value || '').trim();
const pmcidsRaw = (document.getElementById('psb-pmcids').value || '').trim();
const template = document.getElementById('psb-template').value || 'dark';
const slideCount = parseInt(document.getElementById('psb-slide-count').value) || 0;
if (!topic) { pptxSetStatus('주제를 입력하세요.'); return; }
const pmcids = pmcidsRaw
.split(/[\n,]+/)
.map(s => s.trim().replace(/^PMC/i, 'PMC'))
.filter(Boolean);
_pptxImages = [];
_pptxOutline = null;
_pptxOutlineEdits = {};
pptxRenderImageGrid();
const cardsEl = document.getElementById('psb-outline-cards');
if (cardsEl) cardsEl.innerHTML = '아웃라인 생성 중...
';
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
// Build workflow message
const pmcLine = pmcids.length
? `\nPMCIDs: ${pmcids.join(', ')}`
: '';
const slideLine = slideCount ? `\n슬라이드 수: ${slideCount}장` : '';
const msg = `[PPTX 워크플로 시작]\n주제: ${topic}${pmcLine}\n템플릿: ${template}${slideLine}\n\n아래 순서로 진행해주세요:\n1. 작업 폴더 생성 (주제 기반)\n2. PDF 다운로드 (pubmed_fulltext, 위 PMCIDs)\n3. 이미지 추출 (pdf_extract_images, 각 PDF)\n4. 소형 이미지 필터링 (python_eval)\n5. 슬라이드 구성을 \`\`\`pptx-outline 블록으로 출력 (JSON 형식)\n6. 확인 대기 (create_presentation은 아직 호출하지 마세요)`;
pptxSetStatus('워크플로 시작...');
setSidebarTab('pptx');
setPptxSbTab('setup');
// Send via main chat
const input = document.getElementById('chat-input');
if (input) {
input.value = msg;
sendChat();
}
}
function pptxCaptureImages(toolResultText) {
// Parse image links from pdf_extract_images output: [label](/api/files/...)
const re = /\[([^\]]*)\]\((\/api\/files\/[^)]+\.(?:png|jpg|jpeg|gif|webp))\)/gi;
let m;
while ((m = re.exec(toolResultText)) !== null) {
const label = m[1];
const url = m[2];
if (!_pptxImages.some(i => i.url === url)) {
_pptxImages.push({ url, label });
}
}
if (_pptxImages.length) {
pptxRenderImageGrid();
const countEl = document.getElementById('psb-img-count');
if (countEl) countEl.textContent = `(${_pptxImages.length})`;
// Auto-switch to images tab if PPTX tab is active
if (sidebarTab === 'pptx') setPptxSbTab('images');
pptxSetStatus(`이미지 ${_pptxImages.length}개 추출됨`);
}
}
function pptxRenderImageGrid() {
const grid = document.getElementById('psb-image-grid');
if (!grid) return;
if (!_pptxImages.length) {
grid.innerHTML = '이미지 추출 후 표시됩니다
';
return;
}
grid.innerHTML = _pptxImages.map((img, idx) => `
${escHtml(img.label)}
`).join('');
}
function pptxImageClick(idx) {
const img = _pptxImages[idx];
if (!img) return;
window.open(img.url, '_blank', 'noopener');
}
function pptxSetOutline(data) {
_pptxOutline = data;
_pptxOutlineEdits = {};
pptxRenderOutlineCards();
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = false; genBtn.style.opacity = '1'; }
if (sidebarTab === 'pptx') setPptxSbTab('outline');
pptxSetStatus(`아웃라인 준비 (${(data.slides || []).length}장)`);
}
const SLIDE_TYPE_ICON = { title:'🏷', section:'📂', content:'📝', image:'🖼', table:'📊', chart:'📈', timeline:'⏳', compare:'⚖' };
function pptxRenderOutlineCards() {
const container = document.getElementById('psb-outline-cards');
if (!container || !_pptxOutline) return;
const slides = _pptxOutline.slides || [];
if (!slides.length) {
container.innerHTML = '슬라이드가 없습니다
';
return;
}
container.innerHTML = slides.map((s, i) => {
const icon = SLIDE_TYPE_ICON[s.type] || '📄';
const imgThumb = s.image_url
? ` `
: `🖼
`;
const bulletsPreview = Array.isArray(s.bullets) && s.bullets.length
? `${s.bullets.slice(0,2).map(b => `• ${b}`).join(' ')}
`
: '';
return `
${i+1}
${imgThumb}
${icon} ${escHtml(s.title || '(제목 없음)')}
${s.type}
${bulletsPreview}
${Array.isArray(s.bullets) ? `` : ''}
`;
}).join('');
}
function pptxToggleEdit(idx) {
const el = document.getElementById('psb-edit-' + idx);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function pptxEditTitle(idx, val) {
if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {};
_pptxOutlineEdits[idx].title = val;
}
function pptxEditBullets(idx, val) {
if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {};
_pptxOutlineEdits[idx].bullets = val.split(/\n\n+/).map(g => g.replace(/^\n+|\n+$/g, '')).filter(g => g.trim() !== '');
}
function pptxGenerate() {
if (!_pptxOutline) return;
// Apply edits to outline
const slides = (_pptxOutline.slides || []).map((s, i) => {
const ed = _pptxOutlineEdits[i];
if (!ed) return s;
return Object.assign({}, s, ed.title !== undefined ? { title: ed.title } : {}, ed.bullets !== undefined ? { bullets: ed.bullets } : {});
});
const edited = Object.assign({}, _pptxOutline, { slides });
const hasEdits = Object.keys(_pptxOutlineEdits).length > 0;
const msg = hasEdits
? `아웃라인을 수정했습니다. 수정된 아웃라인으로 create_presentation을 호출해주세요:\n\`\`\`json\n${JSON.stringify(edited, null, 2)}\n\`\`\``
: `아웃라인 확인했습니다. create_presentation으로 슬라이드를 생성해주세요.`;
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
pptxSetStatus('생성 중...');
const input = document.getElementById('chat-input');
if (input) { input.value = msg; sendChat(); }
}
function pptxResetWorkflow() {
_pptxImages = [];
_pptxOutline = null;
_pptxOutlineEdits = {};
pptxRenderImageGrid();
const cardsEl = document.getElementById('psb-outline-cards');
if (cardsEl) cardsEl.innerHTML = '아웃라인 생성 후 표시됩니다
';
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
const countEl = document.getElementById('psb-img-count');
if (countEl) countEl.textContent = '';
pptxSetStatus('');
setPptxSbTab('setup');
}