- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회) - 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts) - 채널 설정 UI + userMap으로 다중 사용자 계정 매핑 - 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성 - 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응) - 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000) - 교통앱 마지막 선택 지역 localStorage 저장/복원 - docs/kakao-setup.md 작성 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9296 lines
414 KiB
JavaScript
9296 lines
414 KiB
JavaScript
|
||
const _DBG = false; // set true to enable streaming/editor debug logs
|
||
const API = window.location.origin || 'http://127.0.0.1:18789';
|
||
let ws = null;
|
||
let selectedJobId = null;
|
||
let allJobs = [];
|
||
let pollInterval = null;
|
||
let currentMode = 'chat';
|
||
let sidebarTab = 'jobs';
|
||
let useAgentMode = false;
|
||
const CHAT_SESSIONS_KEY_BASE = 'cherryclaw_chat_sessions_v1';
|
||
const AGENT_SESSION_KEY_BASE = 'cherryclaw_agent_session_id';
|
||
const THEME_KEY = 'cherryclaw_theme';
|
||
const TOKEN_KEY = 'smallclaw_token';
|
||
function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } }
|
||
function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} }
|
||
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
|
||
function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; }
|
||
function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; }
|
||
function generateSessionId() {
|
||
return (crypto?.randomUUID?.() || ('sess_' + Math.random().toString(36).slice(2)));
|
||
}
|
||
function getCookie(name) {
|
||
const value = `; ${document.cookie}`;
|
||
const parts = value.split(`; ${name}=`);
|
||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||
return '';
|
||
}
|
||
function setAgentSessionId(id) {
|
||
const next = String(id || '').trim() || generateSessionId();
|
||
agentSessionId = next;
|
||
localStorage.setItem(agentSessionKey(), next);
|
||
return next;
|
||
}
|
||
let agentSessionId = '';
|
||
let currentUser = null;
|
||
setAgentSessionId(generateSessionId()); // always fresh session on page load
|
||
checkAuth(); // Check authentication on page load
|
||
initTTSToggle();
|
||
initMicAutosendToggle();
|
||
|
||
// ── Server log panel ──────────────────────────────────────────────────────────
|
||
let logScrollLocked = true;
|
||
let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size
|
||
// 빈 Set = 전체 표시 / 값 있으면 해당 레벨만 표시
|
||
const logLevelFilter = new Set();
|
||
const logLevelCounts = { log: 0, warn: 0, error: 0 };
|
||
|
||
function toggleLogFilter(level) {
|
||
if (logLevelFilter.has(level)) {
|
||
logLevelFilter.delete(level);
|
||
} else {
|
||
logLevelFilter.add(level);
|
||
}
|
||
applyLogFilters();
|
||
}
|
||
|
||
function setAllLogFilters(on) {
|
||
logLevelFilter.clear();
|
||
if (!on) logLevelFilter.add('__none__');
|
||
applyLogFilters();
|
||
}
|
||
|
||
function applyLogFilters() {
|
||
const body = document.getElementById('sidebar-log-body');
|
||
if (body) {
|
||
const none = logLevelFilter.has('__none__');
|
||
const filtered = !none && logLevelFilter.size > 0;
|
||
body.classList.toggle('filter-active', filtered || none);
|
||
['log', 'warn', 'error'].forEach(lv => {
|
||
body.classList.toggle('hide-' + lv, none || (filtered && !logLevelFilter.has(lv)));
|
||
});
|
||
|
||
// 필터 결과 빈 경우 안내 메시지
|
||
let emptyEl = body.querySelector('.log-empty-hint');
|
||
const none2 = logLevelFilter.has('__none__');
|
||
const filtered2 = !none2 && logLevelFilter.size > 0;
|
||
const visibleCount = none2 ? 0 : filtered2
|
||
? [...logLevelFilter].reduce((s, lv) => s + (logLevelCounts[lv] || 0), 0)
|
||
: logLevelCounts.log + logLevelCounts.warn + logLevelCounts.error;
|
||
if (visibleCount === 0 && (none2 || filtered2)) {
|
||
if (!emptyEl) {
|
||
emptyEl = document.createElement('div');
|
||
emptyEl.className = 'log-empty-hint';
|
||
emptyEl.style.cssText = 'padding:12px;text-align:center;color:var(--muted);font-size:11px;';
|
||
body.appendChild(emptyEl);
|
||
}
|
||
emptyEl.textContent = none2 ? '필터: 없음' : `해당 레벨 메시지 없음 (${[...logLevelFilter].map(l=>l.toUpperCase()).join(', ')})`;
|
||
} else if (emptyEl) {
|
||
emptyEl.remove();
|
||
}
|
||
}
|
||
|
||
// 버튼: 활성 상태 + 카운트
|
||
document.querySelectorAll('.log-filter-btn[data-level]').forEach(btn => {
|
||
const lv = btn.dataset.level;
|
||
const none = logLevelFilter.has('__none__');
|
||
const filtered = !none && logLevelFilter.size > 0;
|
||
btn.classList.toggle('active', !none && (!filtered || logLevelFilter.has(lv)));
|
||
const cnt = logLevelCounts[lv] || 0;
|
||
btn.title = `${lv.toUpperCase()}: ${cnt}건`;
|
||
});
|
||
const allBtn = document.querySelector('.log-filter-all');
|
||
const noneBtn = document.querySelector('.log-filter-none');
|
||
if (allBtn) allBtn.classList.toggle('active', logLevelFilter.size === 0);
|
||
if (noneBtn) noneBtn.classList.toggle('active', logLevelFilter.has('__none__'));
|
||
}
|
||
|
||
let logSearchQuery = '';
|
||
|
||
function onLogSearch(q) {
|
||
logSearchQuery = q.trim().toLowerCase();
|
||
const clearBtn = document.getElementById('log-search-clear');
|
||
const countEl = document.getElementById('log-search-count');
|
||
if (clearBtn) clearBtn.style.display = logSearchQuery ? '' : 'none';
|
||
|
||
const body = document.getElementById('sidebar-log-body');
|
||
if (!body) return;
|
||
|
||
const lines = body.querySelectorAll('.log-line');
|
||
let matchCount = 0;
|
||
|
||
lines.forEach(line => {
|
||
const msgEl = line.querySelector('.log-msg');
|
||
const tsEl = line.querySelector('.log-ts');
|
||
if (!msgEl) return;
|
||
|
||
if (!logSearchQuery) {
|
||
// 검색 없음: 원래 텍스트 복원, no-match 제거
|
||
line.classList.remove('no-match');
|
||
if (msgEl._origText !== undefined) {
|
||
msgEl.textContent = msgEl._origText;
|
||
delete msgEl._origText;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const orig = msgEl._origText !== undefined ? msgEl._origText : msgEl.textContent;
|
||
msgEl._origText = orig;
|
||
const tsText = tsEl ? tsEl.textContent : '';
|
||
const haystack = (orig + ' ' + tsText).toLowerCase();
|
||
const matches = haystack.includes(logSearchQuery);
|
||
|
||
line.classList.toggle('no-match', !matches);
|
||
if (matches) {
|
||
matchCount++;
|
||
// 하이라이트: 대소문자 무시 치환
|
||
const re = new RegExp(logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
msgEl.innerHTML = orig.replace(re, m => `<span class="log-hl">${m}</span>`);
|
||
} else {
|
||
msgEl.textContent = orig;
|
||
}
|
||
});
|
||
|
||
if (countEl) {
|
||
if (logSearchQuery) {
|
||
countEl.textContent = `${matchCount}건`;
|
||
countEl.style.display = '';
|
||
countEl.style.color = matchCount === 0 ? '#ef4444' : 'var(--muted)';
|
||
} else {
|
||
countEl.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleLogPanel() {
|
||
const panel = document.getElementById('sidebar-log-panel');
|
||
if (!panel) return;
|
||
panel.classList.toggle('collapsed');
|
||
localStorage.setItem('logPanelCollapsed', panel.classList.contains('collapsed') ? '1' : '0');
|
||
}
|
||
|
||
function toggleLogScrollLock() {
|
||
logScrollLocked = !logScrollLocked;
|
||
const btn = document.getElementById('log-scroll-lock-btn');
|
||
if (btn) btn.style.opacity = logScrollLocked ? '1' : '0.4';
|
||
}
|
||
|
||
function clearLogPanel() {
|
||
const body = document.getElementById('sidebar-log-body');
|
||
if (body) body.innerHTML = '';
|
||
logLastTotalCount = 0;
|
||
logLevelCounts.log = 0; logLevelCounts.warn = 0; logLevelCounts.error = 0;
|
||
const inp = document.getElementById('log-search-input');
|
||
if (inp) inp.value = '';
|
||
logSearchQuery = '';
|
||
applyLogFilters();
|
||
onLogSearch('');
|
||
}
|
||
|
||
function renderLogEntries(entries) {
|
||
const body = document.getElementById('sidebar-log-body');
|
||
if (!body) return;
|
||
const frag = document.createDocumentFragment();
|
||
for (const e of entries) {
|
||
const lv = e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : 'log';
|
||
logLevelCounts[lv] = (logLevelCounts[lv] || 0) + 1;
|
||
const lvCls = lv === 'error' ? 'error lv-error' : lv === 'warn' ? 'warn lv-warn' : 'lv-log';
|
||
const line = document.createElement('div');
|
||
line.className = 'log-line ' + lvCls;
|
||
const ts = document.createElement('span');
|
||
ts.className = 'log-ts';
|
||
ts.textContent = e.ts;
|
||
const msg = document.createElement('span');
|
||
msg.className = 'log-msg';
|
||
msg.textContent = e.msg;
|
||
line.appendChild(ts);
|
||
line.appendChild(msg);
|
||
frag.appendChild(line);
|
||
}
|
||
body.appendChild(frag);
|
||
if (logScrollLocked) body.scrollTop = body.scrollHeight;
|
||
applyLogFilters();
|
||
if (logSearchQuery) onLogSearch(logSearchQuery);
|
||
}
|
||
|
||
async function pollServerLogs() {
|
||
try {
|
||
const data = await api('/api/logs');
|
||
const entries = data?.entries || [];
|
||
const totalCount = data?.totalCount ?? entries.length;
|
||
if (totalCount <= logLastTotalCount) return;
|
||
|
||
const newCount = totalCount - logLastTotalCount;
|
||
// Slice new entries from the end of the ring (handles wrap-around correctly)
|
||
const newEntries = entries.slice(Math.max(0, entries.length - newCount));
|
||
renderLogEntries(newEntries);
|
||
logLastTotalCount = totalCount;
|
||
} catch {}
|
||
}
|
||
|
||
setInterval(pollServerLogs, 2000);
|
||
pollServerLogs();
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Chat state
|
||
let chatHistory = [];
|
||
let chatSessions = [];
|
||
let activeChatSessionId = null;
|
||
let sessionsEditMode = false;
|
||
let isThinking = false;
|
||
let thinkingStartedAt = 0;
|
||
let thinkingTicker = null;
|
||
let streamingSessionId = null; // which session owns the active stream
|
||
let processLogEntries = [];
|
||
let currentTurnStartIndex = -1;
|
||
let lastAgentMode = '-';
|
||
let lastTurnKind = '-';
|
||
let settingsTab = 'system';
|
||
let liveTaskSteps = [];
|
||
let quickSearchRigor = 'verified';
|
||
let _searchSettingsLoaded = false; // true after openSettings() successfully loads search config
|
||
let _modelSettingsLoaded = false; // true after loadModelSettings() successfully populates model/provider DOM
|
||
let quickThinkingEffort = localStorage.getItem('cherryclaw_quick_thinking_effort') || 'standard';
|
||
let queuedPrompts = [];
|
||
const MAX_QUEUED_PROMPTS = 8;
|
||
let currentPreflightStatus = '';
|
||
let currentProgressLines = [];
|
||
let processLogAutoFollow = true;
|
||
let rightColumnAutoFollow = true;
|
||
|
||
// Agent Execution Tracking
|
||
let agentExecutionMap = new Map(); // Map<agentId, { name, status, task, logs: [] }>
|
||
let agentIdCounter = 0;
|
||
const AGENT_STATUS = { ACTIVE: 'active', COMPLETED: 'completed', PAUSED: 'paused' };
|
||
let agentsConfigList = [];
|
||
let selectedAgentId = '';
|
||
let agentMdEditor = null;
|
||
// (skills cache moved to skillsCache in v2 skills management section)
|
||
let lastHeartbeat = {
|
||
state: 'idle',
|
||
level: '',
|
||
current_step: '-',
|
||
retry_count: 0,
|
||
format_violation_count: 0,
|
||
message: '',
|
||
};
|
||
let lastHeartbeatLogSignature = '';
|
||
|
||
function renderQueuedPromptsPanel() {
|
||
const panel = document.getElementById('queued-prompts-panel');
|
||
const list = document.getElementById('queued-prompts-list');
|
||
const title = document.getElementById('queued-prompts-title');
|
||
if (!panel || !list || !title) return;
|
||
const count = queuedPrompts.length;
|
||
if (count === 0) {
|
||
panel.style.display = 'none';
|
||
list.innerHTML = '';
|
||
return;
|
||
}
|
||
panel.style.display = 'block';
|
||
title.textContent = `Queued prompts (${count})`;
|
||
list.innerHTML = queuedPrompts.map((q, i) => `
|
||
<div class="queued-item">
|
||
<div class="queued-item-text">${escHtml(q)}</div>
|
||
<button class="queued-item-btn" onclick="removeQueuedPrompt(${i})">Remove</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function removeQueuedPrompt(index) {
|
||
if (!Number.isInteger(index) || index < 0 || index >= queuedPrompts.length) return;
|
||
queuedPrompts.splice(index, 1);
|
||
updateQueuedPromptUI();
|
||
}
|
||
|
||
function clearQueuedPrompts() {
|
||
if (!queuedPrompts.length) return;
|
||
queuedPrompts = [];
|
||
addProcessEntry('info', 'Cleared queued prompts.');
|
||
updateQueuedPromptUI();
|
||
}
|
||
|
||
function updateQueuedPromptUI() {
|
||
const badge = document.getElementById('queued-prompts-indicator');
|
||
const sendBtn = document.getElementById('send-btn');
|
||
const input = document.getElementById('chat-input');
|
||
const count = queuedPrompts.length;
|
||
if (badge) {
|
||
if (count > 0) {
|
||
badge.style.display = 'inline';
|
||
badge.textContent = `Queued: ${count}`;
|
||
} else {
|
||
badge.style.display = 'none';
|
||
badge.textContent = '';
|
||
}
|
||
}
|
||
if (sendBtn) {
|
||
sendBtn.disabled = false;
|
||
sendBtn.title = isThinking ? `Queue next prompt${count ? ` (${count} queued)` : ''}` : 'Send';
|
||
}
|
||
if (input) {
|
||
input.placeholder = isThinking
|
||
? 'Assistant is responding. Press Enter/Send to queue next prompt.'
|
||
: 'Type a message... (Enter to send, Shift+Enter for newline)';
|
||
}
|
||
renderQueuedPromptsPanel();
|
||
}
|
||
|
||
function saveChatSessions() {
|
||
localStorage.setItem(chatSessionsKey(), JSON.stringify(chatSessions));
|
||
}
|
||
|
||
function makeSessionTitle(history) {
|
||
const firstUser = (history || []).find(m => m.role === 'user');
|
||
if (!firstUser) return 'New chat';
|
||
// Strip markdown image/link syntax for clean session title
|
||
let text = firstUser.content
|
||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[image]')
|
||
.replace(/\[[^\]]+\]\([^)]+\)/g, (m) => m.replace(/\[([^\]]*)\].*/, '$1'))
|
||
.replace(/```[\s\S]*?```/g, '')
|
||
.replace(/\n/g, ' ')
|
||
.trim();
|
||
return text.slice(0, 42) || 'New chat';
|
||
}
|
||
|
||
function loadChatSessions() {
|
||
const key = chatSessionsKey();
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (raw) {
|
||
chatSessions = JSON.parse(raw);
|
||
} else {
|
||
// Migrate from old non-namespaced key once, then delete it so other users can't see it.
|
||
const oldRaw = localStorage.getItem(CHAT_SESSIONS_KEY_BASE);
|
||
if (oldRaw) {
|
||
chatSessions = JSON.parse(oldRaw);
|
||
localStorage.setItem(key, oldRaw);
|
||
localStorage.removeItem(CHAT_SESSIONS_KEY_BASE);
|
||
} else {
|
||
chatSessions = [];
|
||
}
|
||
}
|
||
} catch {
|
||
chatSessions = [];
|
||
}
|
||
if (!Array.isArray(chatSessions)) chatSessions = [];
|
||
if (chatSessions.length === 0) {
|
||
const id = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2));
|
||
chatSessions.push({ id, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() });
|
||
activeChatSessionId = id;
|
||
setAgentSessionId(id);
|
||
saveChatSessions();
|
||
} else if (!activeChatSessionId || !chatSessions.some(s => s.id === activeChatSessionId)) {
|
||
activeChatSessionId = chatSessions[0].id;
|
||
setAgentSessionId(activeChatSessionId);
|
||
}
|
||
if (!agentSessionId || agentSessionId !== activeChatSessionId) setAgentSessionId(activeChatSessionId);
|
||
syncActiveChat();
|
||
// 서버 세션 목록과 비교해 누락된 세션을 사이드바에 추가
|
||
syncServerSessions();
|
||
}
|
||
|
||
async function syncServerSessions() {
|
||
try {
|
||
const res = await fetch('/api/chat/sessions');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (!data.success || !Array.isArray(data.sessions)) return;
|
||
let changed = false;
|
||
for (const srv of data.sessions) {
|
||
const existing = chatSessions.find(s => s.id === srv.id);
|
||
if (!existing) {
|
||
// 서버에만 있는 세션 → 스텁으로 추가 (history는 열 때 로드)
|
||
chatSessions.push({
|
||
id: srv.id,
|
||
title: srv.title || 'New chat',
|
||
history: [],
|
||
processLog: [],
|
||
createdAt: srv.createdAt,
|
||
updatedAt: srv.lastActiveAt,
|
||
serverOnly: true, // 열 때 서버에서 history 로드
|
||
});
|
||
changed = true;
|
||
} else if (existing.serverOnly) {
|
||
// 이미 스텁으로 있으면 메타만 업데이트
|
||
existing.title = srv.title || existing.title;
|
||
existing.updatedAt = srv.lastActiveAt;
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) {
|
||
saveChatSessions();
|
||
renderSessionsList();
|
||
}
|
||
} catch { /* 서버 미응답 시 무시 */ }
|
||
}
|
||
|
||
function syncActiveChat() {
|
||
const sess = chatSessions.find(s => s.id === activeChatSessionId);
|
||
chatHistory = sess ? (sess.history || []) : [];
|
||
processLogEntries = sess ? (sess.processLog || []) : [];
|
||
liveTaskSteps = []; // clear live steps so a different session never inherits in-progress tool display
|
||
// Mark session as read when opening it
|
||
if (sess) sess.unread = false;
|
||
renderChatMessages();
|
||
renderProcessLog();
|
||
renderLiveStepsPanel();
|
||
renderSessionsList();
|
||
updateStats([]);
|
||
loadAgentSessionContext();
|
||
}
|
||
|
||
function persistActiveChat() {
|
||
const idx = chatSessions.findIndex(s => s.id === activeChatSessionId);
|
||
if (idx === -1) return;
|
||
chatSessions[idx].history = chatHistory;
|
||
chatSessions[idx].processLog = processLogEntries;
|
||
chatSessions[idx].title = makeSessionTitle(chatHistory);
|
||
chatSessions[idx].updatedAt = Date.now();
|
||
saveChatSessions();
|
||
renderSessionsList();
|
||
updateStats([]);
|
||
}
|
||
|
||
function newChatSession() {
|
||
const id = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2));
|
||
chatSessions.unshift({ id, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() });
|
||
activeChatSessionId = id;
|
||
setAgentSessionId(id);
|
||
syncActiveChat();
|
||
}
|
||
|
||
function toggleSessionsEditMode() {
|
||
sessionsEditMode = !sessionsEditMode;
|
||
const btn = document.getElementById('sessions-edit-btn');
|
||
if (btn) btn.textContent = sessionsEditMode ? '완료' : '편집';
|
||
renderSessionsList();
|
||
}
|
||
|
||
function deleteChatSession(id, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
const idx = chatSessions.findIndex(s => s.id === id);
|
||
if (idx === -1) return;
|
||
|
||
chatSessions.splice(idx, 1);
|
||
if (chatSessions.length === 0) {
|
||
const newId = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2));
|
||
chatSessions.push({ id: newId, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() });
|
||
activeChatSessionId = newId;
|
||
setAgentSessionId(newId);
|
||
} else if (activeChatSessionId === id) {
|
||
activeChatSessionId = chatSessions[0].id;
|
||
setAgentSessionId(activeChatSessionId);
|
||
}
|
||
|
||
saveChatSessions();
|
||
syncActiveChat();
|
||
// 서버 세션 파일도 삭제 (재로드 시 재등장 방지)
|
||
api(`/api/chat/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
|
||
}
|
||
|
||
function openSession(id) {
|
||
activeChatSessionId = id;
|
||
setAgentSessionId(id);
|
||
const sess = chatSessions.find(s => s.id === id);
|
||
if (sess && sess.serverOnly && sess.history.length === 0) {
|
||
// 서버에서 history 로드 후 렌더
|
||
fetch(`/api/chat/sessions/${id}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success && Array.isArray(data.history)) {
|
||
sess.history = data.history;
|
||
sess.title = makeSessionTitle(data.history) || sess.title;
|
||
delete sess.serverOnly;
|
||
saveChatSessions();
|
||
syncActiveChat();
|
||
renderSessionsList();
|
||
}
|
||
})
|
||
.catch(() => syncActiveChat());
|
||
renderSessionsList(); // 즉시 세션 활성화 표시
|
||
return;
|
||
}
|
||
syncActiveChat();
|
||
}
|
||
|
||
function markSessionUnread(sessionId) {
|
||
const sess = chatSessions.find(s => s.id === sessionId);
|
||
if (sess && sessionId !== activeChatSessionId) {
|
||
sess.unread = true;
|
||
renderSessionsList();
|
||
}
|
||
}
|
||
|
||
// ---- Mode switching ----
|
||
function setMode(mode) {
|
||
if (!['chat','bgtasks'].includes(mode)) mode = 'chat';
|
||
currentMode = mode;
|
||
document.getElementById('chat-view').style.display = mode === 'chat' ? 'flex' : 'none';
|
||
const bgtView = document.getElementById('bgtasks-view');
|
||
if (bgtView) bgtView.style.display = mode === 'bgtasks' ? 'flex' : 'none';
|
||
const aside = document.querySelector('aside');
|
||
const rightPanel = document.getElementById('right-panel');
|
||
if (aside) aside.style.display = '';
|
||
if (rightPanel) rightPanel.style.display = mode === 'chat' ? '' : 'none';
|
||
// sidebar tab 하이라이트 동기화
|
||
document.getElementById('tab-bgtasks')?.classList.toggle('active', mode === 'bgtasks');
|
||
if (mode === 'bgtasks') refreshBgTasks();
|
||
}
|
||
|
||
// --- Heartbeat / Tasks System -----------------------------------------------
|
||
let hbJobs = [];
|
||
let hbConfig = { enabled: false, intervalMinutes: 30, activeHoursStart: 8, activeHoursEnd: 22 };
|
||
let editingTaskId = null;
|
||
|
||
async function loadTasksFromServer() {
|
||
try {
|
||
const data = await api('/api/tasks');
|
||
if (data.success) {
|
||
hbJobs = data.jobs || [];
|
||
hbConfig = data.config || hbConfig;
|
||
renderHeartbeatPage();
|
||
}
|
||
} catch (err) { console.error('[HB] Load failed:', err); }
|
||
}
|
||
|
||
function renderHeartbeatPage() {
|
||
// Update topbar
|
||
const dot = document.getElementById('hb-dot');
|
||
const label = document.getElementById('hb-label');
|
||
const meta = document.getElementById('hb-meta');
|
||
const btn = document.getElementById('hb-toggle-btn');
|
||
if (dot) dot.className = 'hb-status-dot ' + (hbConfig.enabled ? 'active' : '');
|
||
if (label) label.textContent = hbConfig.enabled ? '하트비트 ON' : '하트비트 OFF';
|
||
if (meta) meta.textContent = `Every ${hbConfig.intervalMinutes}m \u00b7 ${hbConfig.activeHoursStart}:00\u2013${hbConfig.activeHoursEnd}:00`;
|
||
if (btn) { btn.textContent = hbConfig.enabled ? 'Pause' : 'Enable'; btn.className = 'hb-btn' + (hbConfig.enabled ? ' danger' : ' primary'); }
|
||
|
||
// Split jobs into sections
|
||
const running = hbJobs.filter(j => j.status === 'running');
|
||
const queued = hbJobs.filter(j => j.enabled && j.status !== 'running' && j.status !== 'completed' && j.status !== 'paused').sort((a,b) => a.priority - b.priority);
|
||
const paused = hbJobs.filter(j => j.status === 'paused');
|
||
const completed = hbJobs.filter(j => j.status === 'completed' || j.type === 'one-shot' && j.lastRun).slice(0, 15);
|
||
|
||
const empty = document.getElementById('hb-empty');
|
||
if (empty) empty.style.display = hbJobs.length === 0 ? '' : 'none';
|
||
|
||
renderHBSection('hb-section-running', 'hb-running-cards', running, 'running');
|
||
renderHBSection('hb-section-queued', 'hb-queued-cards', [...queued, ...paused], 'queued');
|
||
renderHBSection('hb-section-scheduled', 'hb-scheduled-cards', queued.filter(j => j.schedule), 'scheduled');
|
||
renderHBSection('hb-section-completed', 'hb-completed-cards', completed, 'completed');
|
||
}
|
||
|
||
function renderHBSection(sectionId, cardsId, jobs, sectionType) {
|
||
const section = document.getElementById(sectionId);
|
||
const container = document.getElementById(cardsId);
|
||
if (!section || !container) return;
|
||
section.style.display = jobs.length > 0 ? '' : 'none';
|
||
container.innerHTML = jobs.map(j => renderTaskCard(j, sectionType)).join('');
|
||
}
|
||
|
||
function renderTaskCard(job, sectionType) {
|
||
const statusClass = job.status === 'running' ? 'running' : job.status === 'completed' ? 'completed' : job.status === 'paused' ? 'paused' : '';
|
||
const scheduleText = job.schedule ? cronToHuman(job.schedule) : (job.runAt ? 'One-shot: ' + new Date(job.runAt).toLocaleString() : 'Manual');
|
||
const lastRunText = job.lastRun ? timeAgo(new Date(job.lastRun).getTime()) : 'Never';
|
||
const nextRunText = job.nextRun ? new Date(job.nextRun).toLocaleTimeString('en-US', {hour:'2-digit',minute:'2-digit'}) : '-';
|
||
const resultSnippet = job.lastResult ? (job.lastResult.length > 60 ? job.lastResult.slice(0,60)+'...' : job.lastResult) : '';
|
||
const isOk = /^\s*HEARTBEAT_OK\s*$/i.test(job.lastResult || '');
|
||
|
||
return `<div class="hb-card ${statusClass}" data-job-id="${job.id}">
|
||
<div class="hb-card-drag">${sectionType === 'completed' ? '' : '\u2261'}</div>
|
||
<div class="hb-card-body">
|
||
<div class="hb-card-name">${escHtml(job.name)}</div>
|
||
<div class="hb-card-meta">
|
||
<span>${scheduleText}</span>
|
||
<span>Next: ${nextRunText}</span>
|
||
<span>Last: ${lastRunText}</span>
|
||
${resultSnippet && !isOk ? `<span style="color:var(--text);font-weight:600">${escHtml(resultSnippet)}</span>` : ''}
|
||
${isOk ? '<span style="color:var(--ok)">\u2713 OK</span>' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="hb-card-actions">
|
||
${job.status !== 'running' ? `<button class="hb-card-btn" onclick="runTaskNow('${job.id}')" title="Run now">\u25b6</button>` : '<span style="font-size:11px;color:var(--brand)">Running...</span>'}
|
||
<button class="hb-card-btn" onclick="editTask('${job.id}')" title="Edit">\u270e</button>
|
||
<button class="hb-card-btn" onclick="toggleTaskPause('${job.id}')" title="${job.status === 'paused' ? 'Resume' : 'Pause'}">${job.status === 'paused' ? '\u25b6' : '\u23f8'}</button>
|
||
<button class="hb-card-btn danger" onclick="deleteTask('${job.id}')" title="Delete">\u2715</button>
|
||
${job.sessionId ? `<button class="hb-card-btn" onclick="viewTaskSession('${job.sessionId}')" title="View response">\u2192</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function cronToHuman(expr) {
|
||
if (!expr) return 'Manual';
|
||
const p = expr.split(/\s+/);
|
||
if (p.length !== 5) return expr;
|
||
const [min, hr, dom, , dow] = p;
|
||
if (min.startsWith('*/')) return `Every ${min.slice(2)}m`;
|
||
if (hr === '*') return `Every minute`;
|
||
if (dom === '*' && dow === '*') return `Daily at ${hr}:${min.padStart(2,'0')}`;
|
||
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||
if (dow !== '*') return `${days[Number(dow)] || dow} at ${hr}:${min.padStart(2,'0')}`;
|
||
return expr;
|
||
}
|
||
|
||
async function toggleHeartbeatEnabled() {
|
||
try {
|
||
const data = await api('/api/tasks/config', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ enabled: !hbConfig.enabled }) });
|
||
if (data.success) { hbConfig = data.config; renderHeartbeatPage(); }
|
||
} catch (err) { console.error('[HB] Toggle failed:', err); }
|
||
}
|
||
|
||
async function runTaskNow(id) {
|
||
try { await api(`/api/tasks/${id}/run`, { method: 'POST' }); } catch {}
|
||
}
|
||
|
||
async function deleteTask(id) {
|
||
if (!confirm('Delete this task?')) return;
|
||
try {
|
||
await api(`/api/tasks/${id}`, { method: 'DELETE' });
|
||
hbJobs = hbJobs.filter(j => j.id !== id);
|
||
renderHeartbeatPage();
|
||
} catch {}
|
||
}
|
||
|
||
async function toggleTaskPause(id) {
|
||
const job = hbJobs.find(j => j.id === id);
|
||
if (!job) return;
|
||
const newStatus = job.status === 'paused' ? 'scheduled' : 'paused';
|
||
try {
|
||
const data = await api(`/api/tasks/${id}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ status: newStatus, enabled: newStatus !== 'paused' }) });
|
||
if (data.success) { Object.assign(job, data.job); renderHeartbeatPage(); }
|
||
} catch {}
|
||
}
|
||
|
||
function viewTaskSession(sessionId) {
|
||
// Switch to chat mode and open the automated session
|
||
const session = chatSessions.find(s => s.id === sessionId);
|
||
if (session) {
|
||
setMode('chat');
|
||
openSession(sessionId);
|
||
}
|
||
}
|
||
|
||
function openTaskModal(taskId) {
|
||
editingTaskId = taskId || null;
|
||
const job = taskId ? hbJobs.find(j => j.id === taskId) : null;
|
||
const modal = document.getElementById('task-modal');
|
||
modal.style.display = '';
|
||
modal.innerHTML = `<div class="hb-modal-overlay" onclick="if(event.target===this)closeTaskModal()">
|
||
<div class="hb-modal">
|
||
<h3>${job ? 'Edit Task' : 'Add Task'}</h3>
|
||
<label>Name</label>
|
||
<input id="tm-name" value="${job ? escHtml(job.name) : ''}" placeholder="e.g. Check inbox for urgent emails">
|
||
<label>Prompt (what the LLM should do)</label>
|
||
<textarea id="tm-prompt" placeholder="e.g. Search for any urgent emails or messages I should know about">${job ? escHtml(job.prompt) : ''}</textarea>
|
||
<label>Type</label>
|
||
<select id="tm-type">
|
||
<option value="recurring" ${!job || job.type==='recurring' ? 'selected' : ''}>Recurring (cron)</option>
|
||
<option value="one-shot" ${job?.type==='one-shot' ? 'selected' : ''}>One-shot (run once)</option>
|
||
</select>
|
||
<label>Schedule (cron expression)</label>
|
||
<input id="tm-schedule" value="${job?.schedule || '*/30 * * * *'}" placeholder="*/30 * * * * (every 30 min)">
|
||
<div style="font-size:10px;color:var(--muted);margin-top:2px">Examples: */30 * * * * (every 30m), 0 9 * * * (daily 9am), 0 9 * * 1 (Mon 9am)</div>
|
||
<div class="hb-modal-actions">
|
||
<button class="hb-btn" onclick="closeTaskModal()">Cancel</button>
|
||
<button class="hb-btn primary" onclick="saveTask()">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function editTask(id) { openTaskModal(id); }
|
||
|
||
function closeTaskModal() {
|
||
document.getElementById('task-modal').style.display = 'none';
|
||
editingTaskId = null;
|
||
}
|
||
|
||
async function saveTask() {
|
||
const name = document.getElementById('tm-name').value.trim();
|
||
const prompt = document.getElementById('tm-prompt').value.trim();
|
||
const type = document.getElementById('tm-type').value;
|
||
const schedule = document.getElementById('tm-schedule').value.trim();
|
||
if (!name || !prompt) { alert('Name and prompt are required'); return; }
|
||
try {
|
||
if (editingTaskId) {
|
||
const data = await api(`/api/tasks/${editingTaskId}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) });
|
||
if (data.success) {
|
||
const idx = hbJobs.findIndex(j => j.id === editingTaskId);
|
||
if (idx >= 0) hbJobs[idx] = data.job;
|
||
}
|
||
} else {
|
||
const data = await api('/api/tasks', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) });
|
||
if (data.success) hbJobs.push(data.job);
|
||
}
|
||
closeTaskModal();
|
||
renderHeartbeatPage();
|
||
} catch (err) { alert('Failed to save: ' + err.message); }
|
||
}
|
||
|
||
// ---- Sidebar tabs ----
|
||
function setSidebarTab(tab) {
|
||
sidebarTab = tab;
|
||
const toggle = (id, active) => document.getElementById(id)?.classList.toggle('active', active);
|
||
toggle('tab-jobs', tab === 'jobs');
|
||
toggle('tab-bgtasks', tab === 'bgtasks');
|
||
toggle('tab-skills', tab === 'skills');
|
||
toggle('tab-pptx', tab === 'pptx');
|
||
toggle('tab-schedule', tab === 'schedule');
|
||
document.getElementById('sidebar-jobs').style.display = (tab === 'jobs' || tab === 'bgtasks') ? 'flex' : 'none';
|
||
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
|
||
document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none';
|
||
const schedEl = document.getElementById('sidebar-schedule');
|
||
if (schedEl) schedEl.style.display = tab === 'schedule' ? 'flex' : 'none';
|
||
// 메인 뷰 전환
|
||
if (tab === 'bgtasks') { setMode('bgtasks'); }
|
||
else if (currentMode === 'bgtasks') { setMode('chat'); }
|
||
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
|
||
if (tab === 'schedule') refreshSchedules();
|
||
}
|
||
|
||
// ---- Agent mode toggle ----
|
||
function updateAgentMode() {
|
||
useAgentMode = document.getElementById('agent-mode-toggle').checked;
|
||
document.getElementById('agent-mode-label').textContent = useAgentMode ? '에이전트' : '채팅';
|
||
document.getElementById('agent-mode-badge').classList.toggle('visible', useAgentMode);
|
||
document.getElementById('chat-input').placeholder = useAgentMode
|
||
? 'Ask agent to do something... (has access to tools)'
|
||
: 'Type a message... (Enter to send, Shift+Enter for newline)';
|
||
}
|
||
|
||
// ---- Chat ----
|
||
function renderThinkBlock(thinking) {
|
||
if (!thinking || !thinking.trim()) return '';
|
||
const id = 'think_' + Math.random().toString(36).slice(2);
|
||
const wordCount = thinking.trim().split(/\s+/).length;
|
||
return `
|
||
<div class="think-block">
|
||
<button class="think-toggle" id="${id}_btn" onclick="toggleThink('${id}')">
|
||
<span class="think-icon">...</span>
|
||
<span>Thought process</span>
|
||
<span style="margin-left:auto;opacity:0.5">${wordCount} words</span>
|
||
</button>
|
||
<div class="think-content" id="${id}_body">${escHtml(thinking)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function toggleThink(id) {
|
||
const btn = document.getElementById(id + '_btn');
|
||
const body = document.getElementById(id + '_body');
|
||
if (!btn || !body) return;
|
||
const open = body.style.display !== 'none' && body.style.display !== '';
|
||
body.style.display = open ? 'none' : 'block';
|
||
btn.classList.toggle('open', !open);
|
||
}
|
||
|
||
function renderReactSteps(steps) {
|
||
if (!steps || steps.length === 0) return '';
|
||
const toolSteps = steps.filter(s => s.action);
|
||
if (toolSteps.length === 0) return '';
|
||
|
||
const getSearchDiagnostics = (s) => s?.toolData?.search_diagnostics || s?.diagnostics || null;
|
||
const extractUrls = (s, resultText) => {
|
||
const fromToolData = Array.isArray(s?.toolData?.results)
|
||
? s.toolData.results.map(r => String(r?.url || '').trim()).filter(u => /^https?:\/\//i.test(u))
|
||
: [];
|
||
const fromText = String(resultText || '').match(/https?:\/\/[^\s)]+/g) || [];
|
||
const merged = [...fromToolData, ...fromText.map(u => String(u).trim())];
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const u of merged) {
|
||
if (!u || seen.has(u)) continue;
|
||
seen.add(u);
|
||
out.push(u);
|
||
if (out.length >= 5) break;
|
||
}
|
||
return out;
|
||
};
|
||
const providerSummary = (diag) => {
|
||
if (!diag || !Array.isArray(diag.attempted) || !diag.attempted.length) return '';
|
||
return diag.attempted.map(a => {
|
||
const p = String(a.provider || '').toLowerCase();
|
||
const status = String(a.status || '').toLowerCase();
|
||
if (status === 'success') {
|
||
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
|
||
return `${p}=success${count}`;
|
||
}
|
||
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
|
||
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
|
||
}).join(' | ');
|
||
};
|
||
|
||
const id = 'steps_' + Math.random().toString(36).slice(2);
|
||
const items = toolSteps.map(s => {
|
||
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
|
||
const diag = String(s.action || '') === 'web_search' ? getSearchDiagnostics(s) : null;
|
||
const query = String(diag?.query || s?.params?.query || '').trim();
|
||
const selectedProvider = String(diag?.selected_provider || s?.toolData?.provider || '').toLowerCase();
|
||
const providers = diag ? providerSummary(diag) : '';
|
||
const urls = String(s.action || '') === 'web_search' ? extractUrls(s, resultText) : [];
|
||
return `
|
||
<div class="react-step">
|
||
${s.thought ? `<div class="react-step-thought">${escHtml(s.thought.slice(0, 120))}</div>` : ''}
|
||
<div class="react-step-action">${escHtml(s.action)}</div>
|
||
${resultText ? `<div class="react-step-result${resultText.startsWith('ERROR') ? ' error' : ''}">${escHtml(resultText.slice(0, 160))}${resultText.length > 160 ? '…' : ''}</div>` : ''}
|
||
${diag ? `
|
||
<div style="margin-top:6px;font-size:10px;color:var(--muted);line-height:1.5">
|
||
${query ? `<div><b>query</b>: ${escHtml(query)}</div>` : ''}
|
||
${selectedProvider ? `<div><b>provider hit</b>: ${escHtml(selectedProvider)}</div>` : ''}
|
||
${providers ? `<div><b>providers</b>: ${escHtml(providers)}</div>` : ''}
|
||
</div>
|
||
` : ''}
|
||
${String(s.action || '') === 'web_search' && !diag ? `
|
||
<div style="margin-top:6px;font-size:10px;color:var(--muted);line-height:1.5">
|
||
${selectedProvider ? `<div><b>provider hit</b>: ${escHtml(selectedProvider)}</div>` : ''}
|
||
</div>
|
||
` : ''}
|
||
${urls.length ? `
|
||
<div style="margin-top:6px;font-size:10px;line-height:1.6">
|
||
<div style="color:var(--muted);margin-bottom:2px"><b>sources</b>:</div>
|
||
${urls.slice(0, 3).map((u, i) => `<div>${i + 1}. <a href="${escHtml(u)}" target="_blank" rel="noopener noreferrer">${escHtml(u)}</a></div>`).join('')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="react-steps-toggle" onclick="toggleSteps('${id}')">
|
||
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
|
||
</div>
|
||
<div class="react-steps" id="${id}" style="display:none">${items}</div>
|
||
`;
|
||
}
|
||
|
||
function toggleSteps(id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
const visible = el.style.display !== 'none';
|
||
el.style.display = visible ? 'none' : 'block';
|
||
el.previousElementSibling.textContent = `${visible ? '?' : '?'} ${el.querySelectorAll('.react-step').length} tool step${el.querySelectorAll('.react-step').length !== 1 ? 's' : ''} used`;
|
||
}
|
||
|
||
function renderFileDownloads(steps, processEntries) {
|
||
const seenUrls = new Set(); // normalized (decoded) URLs
|
||
const buttons = [];
|
||
const URL_RE = /\/api\/files\/[^\s)"'\]]+/g;
|
||
|
||
function normalizeUrl(u) {
|
||
try { return decodeURIComponent(u); } catch { return u; }
|
||
}
|
||
|
||
function extractUrls(text) {
|
||
const urls = [];
|
||
let m;
|
||
while ((m = URL_RE.exec(text)) !== null) urls.push(m[0]);
|
||
return urls;
|
||
}
|
||
|
||
// Pre-mark URLs already present in msg.content so tool-result duplicates are suppressed.
|
||
// renderMarkdown() handles content links — we must not render a second button for the same URL.
|
||
// Normalize both sides (decode percent-encoding) before comparing so Korean paths match.
|
||
if (arguments.length > 2 && arguments[2]) {
|
||
const contentText = String(arguments[2]);
|
||
if (contentText.indexOf('/api/files/') !== -1) {
|
||
for (const u of extractUrls(contentText)) seenUrls.add(normalizeUrl(u));
|
||
URL_RE.lastIndex = 0;
|
||
}
|
||
}
|
||
|
||
// Collect /api/files/ URLs from tool results only (not content — already handled above)
|
||
const allUrls = [];
|
||
|
||
// 1. msg.steps (legacy step events)
|
||
if (steps && steps.length) {
|
||
for (const s of steps) {
|
||
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
|
||
allUrls.push(...extractUrls(resultText));
|
||
}
|
||
}
|
||
|
||
// 2. msg.processEntries (stored per-message in chatHistory)
|
||
if (processEntries && processEntries.length) {
|
||
for (const e of processEntries) {
|
||
const txt = String(e.content || '');
|
||
if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt));
|
||
}
|
||
}
|
||
|
||
const _imgExts = /\.(jpg|jpeg|png|gif|webp|heic|heif|bmp|svg|avif|tiff|tif)(\?|$)/i;
|
||
for (const rawUrl of allUrls) {
|
||
const normUrl = normalizeUrl(rawUrl);
|
||
if (seenUrls.has(normUrl)) continue;
|
||
seenUrls.add(normUrl);
|
||
const decoded = (() => { try { return decodeURIComponent(rawUrl); } catch { return rawUrl; } })();
|
||
if (_imgExts.test(decoded)) continue; // images shown inline, no download button needed
|
||
const filename = decoded.split('/').pop() || 'download';
|
||
const isPptx = decoded.toLowerCase().indexOf('.pptx') !== -1;
|
||
if (isPptx) {
|
||
const previewId = 'pptx-chat-' + Math.random().toString(36).substr(2, 9);
|
||
buttons.push(`
|
||
<div class="pptx-file-row" style="margin:6px 0">
|
||
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
|
||
<span class="download-icon">⬇</span> ${escHtml(filename)}</a>
|
||
<button class="pptx-preview-btn" onclick="togglePptxPreview('${escHtml(rawUrl)}', '${previewId}', this)" title="Preview slides">◀ Preview</button>
|
||
<div id="${previewId}" class="pptx-preview-strip" style="display:none"></div>
|
||
</div>`);
|
||
} else {
|
||
buttons.push(`
|
||
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
|
||
<span class="download-icon">⬇</span> ${escHtml(filename)}</a>`);
|
||
}
|
||
}
|
||
return buttons.length ? `<div style="margin-top:8px">${buttons.join(' ')}</div>` : '';
|
||
}
|
||
|
||
function renderAssistantContent(content) {
|
||
const text = String(content || '');
|
||
const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text);
|
||
if (!hasStaged) return `<div class="msg-content">${renderMarkdown(text)}</div>`;
|
||
const chunks = text
|
||
.split(/\n\s*---\s*\n/g)
|
||
.map(s => String(s || '').trim())
|
||
.filter(Boolean);
|
||
const html = chunks.map((chunk, idx) => {
|
||
const m = chunk.match(/^(Initial chat|Execution result|Final chat):\s*\n?/i);
|
||
const title = m ? m[1] : `Section ${idx + 1}`;
|
||
const body = m ? chunk.slice(m[0].length).trim() : chunk;
|
||
return `
|
||
<div class="msg-stage">
|
||
<div class="msg-stage-title">${escHtml(title)}</div>
|
||
<div class="msg-content">${renderMarkdown(body)}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
return `<div class="msg-staged">${html}</div>`;
|
||
}
|
||
|
||
function renderArtifacts(artifacts) {
|
||
const rows = Array.isArray(artifacts) ? artifacts : [];
|
||
if (!rows.length) return '';
|
||
const cards = rows.map((a, idx) => {
|
||
const type = String(a?.type || '').trim();
|
||
const title = String(a?.title || type || `Artifact ${idx + 1}`).trim();
|
||
const status = String(a?.status || 'ok').toLowerCase();
|
||
const pathVal = String(a?.path || '').trim();
|
||
const fromPath = String(a?.from_path || '').trim();
|
||
const toPath = String(a?.to_path || '').trim();
|
||
const summary = String(a?.summary || '').trim();
|
||
const preview = String(a?.preview || '').trim();
|
||
const files = Array.isArray(a?.files) ? a.files.slice(0, 12).map(x => String(x || '')).filter(Boolean) : [];
|
||
const dirs = Array.isArray(a?.directories) ? a.directories.slice(0, 8).map(x => String(x || '')).filter(Boolean) : [];
|
||
const locationTarget = toPath || pathVal || fromPath;
|
||
return `
|
||
<div class="artifact-card">
|
||
<div class="artifact-head">
|
||
<div class="artifact-title">${escHtml(title)}</div>
|
||
<div class="artifact-status ${escHtml(status)}">${escHtml(status)}</div>
|
||
</div>
|
||
${pathVal ? `<div class="artifact-row"><b>Path:</b> <code>${escHtml(pathVal)}</code></div>` : ''}
|
||
${fromPath ? `<div class="artifact-row"><b>From:</b> <code>${escHtml(fromPath)}</code></div>` : ''}
|
||
${toPath ? `<div class="artifact-row"><b>To:</b> <code>${escHtml(toPath)}</code></div>` : ''}
|
||
${summary ? `<div class="artifact-row">${escHtml(summary)}</div>` : ''}
|
||
${files.length ? `<div class="artifact-row"><b>Files:</b> ${escHtml(files.join(', '))}${Array.isArray(a?.files) && a.files.length > files.length ? ' ...' : ''}</div>` : ''}
|
||
${dirs.length ? `<div class="artifact-row"><b>Directories:</b> ${escHtml(dirs.join(', '))}${Array.isArray(a?.directories) && a.directories.length > dirs.length ? ' ...' : ''}</div>` : ''}
|
||
${preview ? `<div class="artifact-preview">${escHtml(preview)}</div>` : ''}
|
||
${locationTarget ? `
|
||
<div class="artifact-actions">
|
||
<button class="artifact-btn" onclick="openInFileLocation('${escHtml(locationTarget).replace(/'/g, ''')}')">Open In File Location</button>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
return `<div class="artifact-list">${cards}</div>`;
|
||
}
|
||
|
||
async function openInFileLocation(targetPath) {
|
||
const p = String(targetPath || '').trim();
|
||
if (!p) return;
|
||
try {
|
||
const res = await fetch('/api/open-path', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ path: p }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || !data?.ok) {
|
||
throw new Error(String(data?.error || `HTTP ${res.status}`));
|
||
}
|
||
addProcessEntry('info', `Opened in file location: ${p}`);
|
||
} catch (err) {
|
||
addProcessEntry('error', `Open path failed: ${String(err?.message || err)}`);
|
||
}
|
||
}
|
||
|
||
function renderChatMessages() {
|
||
const container = document.getElementById('chat-messages');
|
||
|
||
if (chatHistory.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="chat-welcome" id="chat-welcome">
|
||
<div class="chat-welcome-icon"><img src="cherry_logo.png" alt="🍒"></div>
|
||
<h2>CherryClaw</h2>
|
||
<p>Chat directly with your local model. No API keys, no cloud — just you and the model.</p>
|
||
<div class="hint">Enable Agent mode when you want planning + tool execution in this chat.</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = chatHistory.map((msg, idx) => {
|
||
const pinClass = contextPinMode ? ' pin-selectable' : '';
|
||
const selectedClass = (contextPinMode && pinnedMessageIndices.has(idx)) ? ' pin-selected' : '';
|
||
// Also show green for confirmed pins even outside pin mode
|
||
const confirmedClass = (!contextPinMode && confirmedPins.some(p => p.content === msg.content && p.role === (msg.role === 'user' ? 'user' : 'assistant'))) ? ' pin-selected' : '';
|
||
const clickHandler = contextPinMode ? ` onclick="togglePinMessage(${idx})"` : '';
|
||
if (msg.role === 'widget') {
|
||
return `<div class="msg widget${pinClass}${selectedClass}${confirmedClass}"${clickHandler}><div class="msg-content">${msg.content}</div></div>`;
|
||
}
|
||
return `
|
||
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
|
||
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
|
||
<div class="msg-body">
|
||
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}${msg.role === 'ai' ? ` <button class="tts-btn" onclick="playTTSMsg(this,${idx})" title="음성으로 듣기">🔊</button>` : ''}</div>
|
||
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}</div>`}
|
||
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
|
||
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
|
||
${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''}
|
||
${msg.role === 'ai' ? renderThinkBlock(msg.thinking) : ''}
|
||
${msg.role === 'ai' ? renderReactSteps(msg.steps) : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
validateDownloadLinks(container);
|
||
|
||
if (isThinking && streamingSessionId === activeChatSessionId) {
|
||
const progressHtml = currentProgressLines.length
|
||
? `<div style="margin:6px 0 8px 0;font-size:11px;line-height:1.6;color:var(--muted)">
|
||
${currentProgressLines.map((l) => `<div>• ${escHtml(l)}</div>`).join('')}
|
||
</div>`
|
||
: '';
|
||
container.innerHTML += `
|
||
<div class="msg ai" id="thinking-msg">
|
||
<div class="msg-avatar"><img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px"></div>
|
||
<div class="msg-body">
|
||
<div class="msg-role">CherryClaw${useAgentMode ? ' (Agent)' : ''}</div>
|
||
${currentPreflightStatus ? `<div class="msg-content" style="margin-bottom:6px;color:#26487e">${escHtml(currentPreflightStatus)}</div>` : ''}
|
||
${progressHtml}
|
||
<div class="thinking" style="align-items:center">
|
||
<div class="thinking-dot"></div>
|
||
<div class="thinking-dot"></div>
|
||
<div class="thinking-dot"></div>
|
||
<span id="thinking-elapsed" style="margin-left:8px;font-size:11px;color:var(--muted)"></span>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="toggleCurrentProcess()">Process</button>
|
||
<div id="current-turn-process" style="display:none;margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);padding:8px;max-height:220px;overflow:auto;font-size:11px;line-height:1.6"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function pushProgressLine(line) {
|
||
const txt = String(line || '').trim();
|
||
if (!txt) return;
|
||
const last = currentProgressLines[currentProgressLines.length - 1] || '';
|
||
if (last === txt) return;
|
||
currentProgressLines.push(txt);
|
||
if (currentProgressLines.length > 8) currentProgressLines = currentProgressLines.slice(-8);
|
||
if (isThinking) renderChatMessages();
|
||
}
|
||
|
||
// ---- Process log ----
|
||
function renderProcessPill(entries) {
|
||
const id = 'proc_' + Math.random().toString(36).slice(2);
|
||
return `
|
||
<div style="margin-top:8px">
|
||
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="togglePL('${id}')">Process</button>
|
||
<div id="${id}" style="display:none;margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);padding:8px;max-height:220px;overflow:auto;font-size:11px;line-height:1.6">
|
||
${formatProcessLines(entries)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function formatProcessLines(entries) {
|
||
if (!entries || entries.length === 0) return '<div style="color:var(--muted)">No process details.</div>';
|
||
const TYPE_COLORS = {
|
||
user: 'var(--text)', think: '#388bfd', tool: '#e3b341',
|
||
result: '#bc8cff', error: 'var(--red)', final: '#56d364',
|
||
warn: 'var(--yellow)', step: '#56d364', info: '#79c0ff',
|
||
split: 'var(--accent2)', synth: 'var(--accent2)',
|
||
};
|
||
return entries.map(e => {
|
||
const rawType = String(e.type || 'info');
|
||
const typeLabel = escHtml(rawType).toUpperCase();
|
||
const typeColor = TYPE_COLORS[rawType] || 'var(--muted)';
|
||
const rawContent = String(e.content || '');
|
||
// Render markdown for result entries so download links show as clickable buttons
|
||
const content = rawType === 'result' ? renderMarkdown(rawContent) : escHtml(rawContent);
|
||
return `<div style="margin-bottom:4px"><span style="color:var(--muted)">[${escHtml(String(e.ts || ''))}]</span> <span style="color:${typeColor};font-weight:600">${typeLabel}</span> ${content}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleCurrentProcess() {
|
||
const panel = document.getElementById('current-turn-process');
|
||
if (!panel) return;
|
||
const open = panel.style.display !== 'none';
|
||
panel.style.display = open ? 'none' : 'block';
|
||
if (!open) {
|
||
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
|
||
panel.innerHTML = formatProcessLines(turnEntries);
|
||
}
|
||
}
|
||
|
||
function isNearBottom(el, threshold = 40) {
|
||
if (!el) return true;
|
||
return (el.scrollHeight - (el.scrollTop + el.clientHeight)) <= threshold;
|
||
}
|
||
|
||
function setupProcessAndRightScrollTracking() {
|
||
const processEl = document.getElementById('process-log');
|
||
if (processEl && !processEl.dataset.scrollBound) {
|
||
processEl.dataset.scrollBound = '1';
|
||
processEl.addEventListener('scroll', () => {
|
||
processLogAutoFollow = isNearBottom(processEl, 36);
|
||
});
|
||
}
|
||
|
||
const rightEl = document.getElementById('right-panel');
|
||
if (rightEl && !rightEl.dataset.scrollBound) {
|
||
rightEl.dataset.scrollBound = '1';
|
||
rightEl.addEventListener('scroll', () => {
|
||
rightColumnAutoFollow = isNearBottom(rightEl, 28);
|
||
});
|
||
}
|
||
}
|
||
|
||
function maybeAutoScrollRightColumn(force = false) {
|
||
const rightEl = document.getElementById('right-panel');
|
||
if (!rightEl) return;
|
||
if (force || rightColumnAutoFollow || isThinking) {
|
||
rightEl.scrollTop = rightEl.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function updateHeartbeatUI() {
|
||
const stateEl = document.getElementById('r-heartbeat-state');
|
||
const metaEl = document.getElementById('r-heartbeat-meta');
|
||
if (stateEl) {
|
||
const state = String(lastHeartbeat.state || 'idle').toLowerCase();
|
||
stateEl.textContent = state || 'idle';
|
||
stateEl.style.color = state === 'running' || state === 'retrying'
|
||
? 'var(--warn)'
|
||
: (state === 'stalled' ? 'var(--err)' : 'var(--text)');
|
||
}
|
||
if (metaEl) {
|
||
const step = String(lastHeartbeat.current_step || '-');
|
||
const retries = Number(lastHeartbeat.retry_count || 0);
|
||
const fv = Number(lastHeartbeat.format_violation_count || 0);
|
||
const lvl = String(lastHeartbeat.level || '').toLowerCase();
|
||
const lvlTag = lvl ? ` ${lvl}` : '';
|
||
metaEl.textContent = `step:${step} • retries:${retries} • fv:${fv}${lvlTag}`;
|
||
}
|
||
}
|
||
|
||
function clearProcessLog() {
|
||
processLogEntries.length = 0;
|
||
const el = document.getElementById('process-log');
|
||
if (el) el.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>';
|
||
processLogAutoFollow = true;
|
||
rightColumnAutoFollow = true;
|
||
maybeAutoScrollRightColumn(true);
|
||
persistActiveChat();
|
||
}
|
||
|
||
function addProcessEntry(type, content, extra) {
|
||
const ts = new Date().toLocaleTimeString();
|
||
processLogEntries.push({ ts, type, content, extra });
|
||
renderProcessLog();
|
||
maybeAutoScrollRightColumn(isThinking || rightColumnAutoFollow);
|
||
persistActiveChat();
|
||
if (isThinking) {
|
||
const panel = document.getElementById('current-turn-process');
|
||
if (panel && panel.style.display !== 'none' && currentTurnStartIndex >= 0) {
|
||
panel.innerHTML = formatProcessLines(processLogEntries.slice(currentTurnStartIndex));
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderProcessLog() {
|
||
const el = document.getElementById('process-log');
|
||
if (!el) return;
|
||
setupProcessAndRightScrollTracking();
|
||
const shouldFollow = processLogAutoFollow || isThinking;
|
||
|
||
if (processLogEntries.length === 0) {
|
||
el.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>';
|
||
if (shouldFollow) el.scrollTop = el.scrollHeight;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = processLogEntries.map((e,i) => {
|
||
let icon, color, label, labelColor;
|
||
switch(e.type) {
|
||
case 'user': icon='?'; color='var(--accent2)'; labelColor='var(--text)'; label='USER'; break;
|
||
case 'think': icon='??'; color='#388bfd'; labelColor='#388bfd'; label='THINK'; break;
|
||
case 'tool': icon='?'; color='#e3b341'; labelColor='#e3b341'; label='TOOL'; break;
|
||
case 'result': icon='?'; color='#bc8cff'; labelColor='#bc8cff'; label='OK'; break;
|
||
case 'error': icon='?'; color='var(--red)'; labelColor='var(--red)'; label='FAIL'; break;
|
||
case 'final': icon='?'; color='#56d364'; labelColor='#56d364'; label='FINAL'; break;
|
||
case 'warn': icon='?'; color='var(--yellow)'; labelColor='var(--yellow)'; label='WARN'; break;
|
||
case 'step': icon='?'; color='#56d364'; labelColor='#56d364'; label='STEP'; break;
|
||
case 'split': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SPLIT'; break;
|
||
case 'synth': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SYNTH'; break;
|
||
case 'info': icon='·'; color='#79c0ff'; labelColor='#79c0ff'; label='INFO'; break;
|
||
default: icon='·'; color='var(--muted)'; labelColor='var(--muted)'; label=e.type; break;
|
||
}
|
||
|
||
// Think entries: show full content in a scrollable block
|
||
if (e.type === 'think' && e.content && e.content.length > 60) {
|
||
const id = 'pl_' + Math.random().toString(36).slice(2,8);
|
||
const preview = e.content.slice(0, 80).replace(/\n/g,' ');
|
||
return `
|
||
<div style="margin-bottom:4px">
|
||
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
|
||
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
|
||
<span style="color:var(--muted);cursor:pointer;text-decoration:underline;font-size:10px" onclick="togglePL('${id}')">${escHtml(preview)}…</span>
|
||
</div>
|
||
<div id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;white-space:pre-wrap;color:var(--muted);font-size:10px;max-height:200px;overflow-y:auto;font-style:italic">${escHtml(e.content)}</div>
|
||
`;
|
||
}
|
||
|
||
// Tool result entries: collapsible if long
|
||
if (e.type === 'result' && e.content && e.content.length > 120) {
|
||
const id = 'pl_' + Math.random().toString(36).slice(2,8);
|
||
const preview = e.content.slice(0, 100).replace(/\n/g,' ');
|
||
return `
|
||
<div style="margin-bottom:4px">
|
||
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
|
||
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
|
||
<span style="color:${color};cursor:pointer;font-size:10px" onclick="togglePL('${id}')">${escHtml(preview)}… <span style="opacity:0.5">[expand]</span></span>
|
||
</div>
|
||
<div id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;white-space:pre-wrap;color:var(--text);font-size:10px;max-height:300px;overflow-y:auto">${escHtml(e.content)}</div>
|
||
`;
|
||
}
|
||
|
||
// Memory suggestion: show approve/reject buttons
|
||
if (e.type === 'memory') {
|
||
const id = 'pl_mem_' + i;
|
||
const suggest = e.extra || {};
|
||
return `
|
||
<div style="margin-bottom:6px">
|
||
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
|
||
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
|
||
<span style="color:var(--text);">${escHtml(String(e.content || ''))}</span>
|
||
<div style="margin-top:6px">
|
||
<button class="btn btn-sm" onclick="confirmMemory(${i})">Approve</button>
|
||
<button class="btn btn-sm" style="margin-left:6px" onclick="rejectMemory(${i})">Reject</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// If an 'extra' object/array was provided, show a details toggle
|
||
if (e.extra) {
|
||
const id = 'pl_extra_' + i;
|
||
const pretty = escHtml(JSON.stringify(e.extra, null, 2));
|
||
return `
|
||
<div style="margin-bottom:3px">
|
||
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
|
||
<span style="color:${color}">${icon} <span style="color:${labelColor}">${label}</span></span>
|
||
<span style="color:var(--text);margin-left:8px">${escHtml(String(e.content || ''))}</span>
|
||
<span style="color:var(--muted);cursor:pointer;margin-left:8px;font-size:10px" onclick="togglePL('${id}')">[details]</span>
|
||
</div>
|
||
<pre id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;color:var(--muted);font-size:11px;max-height:300px;overflow:auto">${pretty}</pre>
|
||
`;
|
||
}
|
||
|
||
return `<div style="margin-bottom:3px"><span style="color:var(--muted);font-size:10px">${e.ts}</span> <span style="color:${color}">${icon} <span style="color:${labelColor}">${label}</span></span> <span style="color:var(--text)">${escHtml(String(e.content || ''))}</span></div>`;
|
||
}).join('');
|
||
|
||
if (shouldFollow) el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function togglePL(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
function isFailedTurnReply(text) {
|
||
const t = String(text || '').trim().toLowerCase();
|
||
if (!t) return true;
|
||
if (/^blocked\b/.test(t)) return true;
|
||
if (/^no response received\.?$/.test(t)) return true;
|
||
if (/^connection error:/.test(t)) return true;
|
||
if (/^sorry, i didn'?t catch that/.test(t)) return true;
|
||
if (/could not|couldn't/.test(t) && /(verify|format|synthes|extract|read|write|tool|request)/.test(t)) return true;
|
||
return false;
|
||
}
|
||
|
||
// ---- Send chat (SSE version) ----
|
||
async function sendChat(queuedMessage = null) {
|
||
const input = document.getElementById('chat-input');
|
||
const sendBtn = document.getElementById('send-btn');
|
||
const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value;
|
||
const message = String(raw || '').trim();
|
||
if (!message && stagedAttachments.length === 0) return;
|
||
if (isThinking) {
|
||
if (typeof queuedMessage === 'string') return;
|
||
if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
|
||
addProcessEntry('warn', `Queue full (${MAX_QUEUED_PROMPTS}). Wait for current run to finish.`);
|
||
return;
|
||
}
|
||
queuedPrompts.push(message);
|
||
addProcessEntry('info', `Queued prompt #${queuedPrompts.length}. It will run automatically next.`);
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
updateQueuedPromptUI();
|
||
return;
|
||
}
|
||
|
||
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
|
||
let fullMessage = message;
|
||
let displayMessage = message;
|
||
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
|
||
const attachParts = stagedAttachments
|
||
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
|
||
.map(function(a) {
|
||
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
|
||
});
|
||
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : '');
|
||
const compactParts = stagedAttachments
|
||
.filter(function(a) { return a.type !== 'txt-auto'; })
|
||
.map(function(a) {
|
||
// [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링
|
||
var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**';
|
||
// PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가
|
||
if (a.type === 'pdf' && a.txtUrl) {
|
||
line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')';
|
||
}
|
||
return line;
|
||
});
|
||
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : '');
|
||
clearStagedAttachments();
|
||
}
|
||
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
|
||
persistActiveChat();
|
||
if (typeof queuedMessage !== 'string') {
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
}
|
||
isThinking = true;
|
||
thinkingStartedAt = Date.now();
|
||
if (thinkingTicker) clearInterval(thinkingTicker);
|
||
thinkingTicker = setInterval(() => {
|
||
const el = document.getElementById('thinking-elapsed');
|
||
if (el) el.textContent = `${Math.round((Date.now() - thinkingStartedAt) / 1000)}s 경과`;
|
||
}, 1000);
|
||
streamingSessionId = activeChatSessionId; // lock bubble to this session
|
||
setButtonState(true);
|
||
currentPreflightStatus = '';
|
||
currentProgressLines = [];
|
||
liveTaskSteps = [];
|
||
if (typeof _v2LiveState !== 'undefined') { _v2LiveState.fileId = null; _v2LiveState.inBlock = false; }
|
||
lastHeartbeat = {
|
||
state: 'running',
|
||
level: '',
|
||
current_step: 'dispatch',
|
||
retry_count: 0,
|
||
format_violation_count: 0,
|
||
message: 'Turn started',
|
||
};
|
||
lastHeartbeatLogSignature = '';
|
||
processLogAutoFollow = true;
|
||
rightColumnAutoFollow = true;
|
||
updateHeartbeatUI();
|
||
renderLiveStepsPanel();
|
||
sendBtn.disabled = false;
|
||
updateQueuedPromptUI();
|
||
renderChatMessages();
|
||
maybeAutoScrollRightColumn(true);
|
||
|
||
currentTurnStartIndex = processLogEntries.length;
|
||
addProcessEntry('user', message);
|
||
|
||
// 서버(session.ts)가 세션 히스토리를 디스크에 영구 저장하므로 클라이언트 history 전송 불필요.
|
||
// historyOverride를 보내면 서버 세션을 덮어써서 오히려 오작동 + 대용량 413 원인이 됨.
|
||
const historyForAPI = undefined;
|
||
|
||
const allSteps = [];
|
||
let finalReply = '';
|
||
let finalArtifacts = [];
|
||
const turnThinkingBuffer = [];
|
||
const turnThinkingSeen = new Set();
|
||
let sawExecuteModeThisTurn = false;
|
||
let sawToolActivityThisTurn = false;
|
||
let turnFileLinks = [];
|
||
const collectTurnThinking = (value) => {
|
||
const text = String(value || '').trim();
|
||
if (!text) return;
|
||
const key = text.replace(/\s+/g, ' ').trim();
|
||
if (!key || turnThinkingSeen.has(key)) return;
|
||
turnThinkingSeen.add(key);
|
||
turnThinkingBuffer.push(text);
|
||
};
|
||
|
||
try {
|
||
// Use SSE fetch — stream steps live as they arrive
|
||
currentAbortController = new AbortController();
|
||
let partialContent = '';
|
||
|
||
const res = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: authHeaders({
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
}),
|
||
signal: currentAbortController.signal,
|
||
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`);
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop(); // keep incomplete line
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue;
|
||
let event;
|
||
try { event = JSON.parse(line.slice(6)); } catch { continue; }
|
||
|
||
switch (event.type) {
|
||
case 'agent_mode':
|
||
lastAgentMode = event.mode || '-';
|
||
lastTurnKind = event.turnKind || lastTurnKind;
|
||
if (event.mode === 'execute') sawExecuteModeThisTurn = true;
|
||
addProcessEntry(
|
||
'info',
|
||
`Agent mode: ${event.mode || 'unknown'}${event.turnKind ? ` (${event.turnKind})` : ''}${event.switched_from ? ` | switched_from=${event.switched_from}` : ''}${event.route_target ? ` | route_target=${event.route_target}` : ''}${event.trigger ? ` | trigger=${event.trigger}` : ''}`
|
||
);
|
||
if (event.mode === 'execute') renderLiveStepsPanel();
|
||
loadAgentSessionContext();
|
||
break;
|
||
|
||
case 'session_mode_locked':
|
||
addProcessEntry('info', `Session mode locked: ${event.mode || 'unknown'}`);
|
||
break;
|
||
|
||
case 'decomposed':
|
||
addProcessEntry('split', `Split into ${event.questions.length} sub-questions: ${event.questions.map((q,i) => `Q${i+1}: ${q.slice(0,40)}`).join(' | ')}`);
|
||
break;
|
||
|
||
case 'thinking':
|
||
if (event.thinking && String(event.thinking).trim()) {
|
||
const thinkingText = String(event.thinking).trim();
|
||
collectTurnThinking(thinkingText);
|
||
addProcessEntry('think', thinkingText);
|
||
}
|
||
break;
|
||
|
||
case 'token':
|
||
case 'text_delta': {
|
||
const token = event.text || event.content || event.delta || '';
|
||
if (token) {
|
||
partialContent += token;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'info':
|
||
if (event.message) addProcessEntry('info', String(event.message));
|
||
break;
|
||
|
||
case 'heartbeat': {
|
||
lastHeartbeat = {
|
||
...lastHeartbeat,
|
||
state: event.state || lastHeartbeat.state,
|
||
level: event.level || lastHeartbeat.level,
|
||
current_step: event.current_step || lastHeartbeat.current_step,
|
||
retry_count: Number(event.retry_count || lastHeartbeat.retry_count || 0),
|
||
format_violation_count: Number(event.format_violation_count || lastHeartbeat.format_violation_count || 0),
|
||
message: event.message || lastHeartbeat.message || '',
|
||
};
|
||
updateHeartbeatUI();
|
||
const sig = `${lastHeartbeat.state}|${lastHeartbeat.level}|${lastHeartbeat.current_step}|${lastHeartbeat.retry_count}|${lastHeartbeat.format_violation_count}`;
|
||
if (sig !== lastHeartbeatLogSignature) {
|
||
const level = String(lastHeartbeat.level || '').toLowerCase();
|
||
const text = String(lastHeartbeat.message || `state=${lastHeartbeat.state} step=${lastHeartbeat.current_step}`).trim();
|
||
addProcessEntry(level === 'hard' ? 'warn' : 'info', `Heartbeat: ${text}`);
|
||
lastHeartbeatLogSignature = sig;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'ui_preflight':
|
||
if (event.message) {
|
||
currentPreflightStatus = String(event.message);
|
||
pushProgressLine(currentPreflightStatus);
|
||
renderChatMessages();
|
||
}
|
||
break;
|
||
|
||
case 'tool_call': {
|
||
const action = String(event.action || '').trim();
|
||
const stepNum = Number(event.stepNum || 0);
|
||
const args = (event.args && typeof event.args === 'object') ? event.args : null;
|
||
const argsPreview = args ? JSON.stringify(args).slice(0, 260) : '';
|
||
const syntheticTag = event.synthetic ? ' [synthetic]' : '';
|
||
sawToolActivityThisTurn = true;
|
||
if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}Running ${action}...`);
|
||
upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), 'in_progress', 'running');
|
||
if (action) {
|
||
addProcessEntry(
|
||
'tool',
|
||
`${stepNum ? `Step ${stepNum}: ` : ''}${action}${syntheticTag}${argsPreview ? ` ${argsPreview}` : ''}`,
|
||
args || undefined,
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'tool_result': {
|
||
const action = String(event.action || '').trim();
|
||
const stepNum = Number(event.stepNum || 0);
|
||
sawToolActivityThisTurn = true;
|
||
const text = String(event.result || '');
|
||
const ok = event.error === true ? false : !/^ERROR:/i.test(text);
|
||
const syntheticTag = event.synthetic ? ' [synthetic]' : '';
|
||
if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}${action} ${ok ? 'complete' : 'failed'}`);
|
||
upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), ok ? 'done' : 'failed', ok ? 'completed' : text.slice(0, 120));
|
||
addProcessEntry(
|
||
ok ? 'result' : 'error',
|
||
`${stepNum ? `Step ${stepNum}: ` : ''}${action || 'tool'}${syntheticTag} => ${text || '(no output)'}`,
|
||
);
|
||
loadAgentSessionContext();
|
||
// PPTX: capture extracted images
|
||
if (ok && action === 'pdf_extract_images') pptxCaptureImages(text);
|
||
break;
|
||
}
|
||
|
||
case 'orchestration': {
|
||
try {
|
||
if (typeof window !== 'undefined' && typeof window._orchEventHandler === 'function') {
|
||
window._orchEventHandler(event);
|
||
}
|
||
} catch {}
|
||
const trigger = String(event.trigger || 'unknown');
|
||
const mode = String(event.mode || 'rescue');
|
||
const route = event.route ? ` | route=${event.route}` : '';
|
||
const reason = event.reason ? ` | ${event.reason}` : '';
|
||
const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap)))
|
||
? ` | assists ${event.assist_count}/${event.assist_cap}`
|
||
: '';
|
||
addProcessEntry('info', `Orchestrator: trigger=${trigger} mode=${mode}${route}${reason}${cap}`, event);
|
||
if (event.preflight?.quick_plan?.length) {
|
||
addProcessEntry('info', `Orchestrator plan: ${event.preflight.quick_plan[0]}`);
|
||
} else if (event.advice?.next_actions?.length) {
|
||
addProcessEntry('info', `Orchestrator next: ${event.advice.next_actions[0]}`);
|
||
}
|
||
const preflightRaw = String(event.preflight?.raw_response || '').trim();
|
||
if (preflightRaw) {
|
||
addProcessEntry('think', `[Secondary AI preflight raw response]\n${preflightRaw}`);
|
||
}
|
||
const adviceRaw = String(event.advice?.raw_response || '').trim();
|
||
if (adviceRaw) {
|
||
addProcessEntry('think', `[Secondary AI advisor raw response]\n${adviceRaw}`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'browser_advisor_start': {
|
||
const pageType = event.page_type ? ` type=${event.page_type}` : '';
|
||
const count = Number.isFinite(Number(event.extracted_count)) ? ` | extracted=${event.extracted_count}` : '';
|
||
addProcessEntry('info', `Browser advisor start:${pageType}${count}`);
|
||
break;
|
||
}
|
||
|
||
case 'feed_collected': {
|
||
const b = Number(event.batch || 0);
|
||
const added = Number(event.added || 0);
|
||
const total = Number(event.total || 0);
|
||
const deduped = Number(event.deduped || 0);
|
||
addProcessEntry('info', `Feed collected: batch ${b} | +${added} | total ${total} | deduped ${deduped}`);
|
||
break;
|
||
}
|
||
|
||
case 'browser_advisor_route': {
|
||
const route = String(event.route || 'unknown');
|
||
const reason = event.reason ? ` | ${String(event.reason)}` : '';
|
||
const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap)))
|
||
? ` | assists ${event.assist_count}/${event.assist_cap}`
|
||
: '';
|
||
const nextTool = event.next_tool?.tool ? ` | next=${event.next_tool.tool}` : '';
|
||
addProcessEntry('info', `Browser advisor route=${route}${nextTool}${reason}${cap}`, event);
|
||
const rawResponse = String(event.raw_response || '').trim();
|
||
if (rawResponse) {
|
||
addProcessEntry('think', `[Secondary AI browser advisor raw response]\n${rawResponse}`);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'browser_advisor_nudge': {
|
||
const route = event.route ? `[${event.route}] ` : '';
|
||
const preview = String(event.preview || '').trim();
|
||
if (preview) addProcessEntry('info', `Advisor nudge ${route}${preview}`);
|
||
break;
|
||
}
|
||
|
||
case 'forced_retry': {
|
||
const reason = String(event.reason || 'advisor requested continuation');
|
||
const retry = Number(event.retry || 0);
|
||
const max = Number(event.max_retries || 0);
|
||
addProcessEntry('warn', `Forced retry ${retry}/${max}: ${reason}`);
|
||
break;
|
||
}
|
||
|
||
case 'preempt_start': {
|
||
const elapsedSec = Math.max(1, Math.round(Number(event.elapsed_ms || 0) / 1000));
|
||
const thresholdSec = Math.max(1, Math.round(Number(event.threshold_ms || 0) / 1000));
|
||
addProcessEntry('warn', `Preempt start: generation stalled ${elapsedSec}s (threshold ${thresholdSec}s).`);
|
||
break;
|
||
}
|
||
|
||
case 'preempt_killed': {
|
||
const restarted = event.restarted === true;
|
||
const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap)))
|
||
? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}`
|
||
: '';
|
||
addProcessEntry(restarted ? 'info' : 'warn', `Preempt kill/restart ${restarted ? 'completed' : 'failed'}${cap}`);
|
||
break;
|
||
}
|
||
|
||
case 'preempt_ready': {
|
||
const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap)))
|
||
? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}`
|
||
: '';
|
||
addProcessEntry('info', `Preempt ready: Ollama online, running rescue advisor${cap}`);
|
||
break;
|
||
}
|
||
|
||
case 'preempt_rescue': {
|
||
const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap)))
|
||
? ` | assists ${event.assist_count}/${event.assist_cap}`
|
||
: '';
|
||
addProcessEntry('info', `Preempt rescue guidance injected${cap}`);
|
||
break;
|
||
}
|
||
|
||
case 'preempt_retry':
|
||
addProcessEntry('info', 'Preempt retry: re-running primary with rescue context.');
|
||
break;
|
||
|
||
case 'synthesizing':
|
||
pushProgressLine(`Synthesizing ${Number(event.count || 1)} answer(s)...`);
|
||
addProcessEntry('synth', `Combining ${event.count} answers...`);
|
||
break;
|
||
|
||
case 'step': {
|
||
const s = event;
|
||
allSteps.push(s);
|
||
|
||
// Show step number
|
||
if (s.stepNum && !s.isFormatViolation && !s.finalAnswer && !s.action) {
|
||
// just a step counter with no other info — skip
|
||
}
|
||
|
||
// Thinking block
|
||
if (s.thinking) {
|
||
collectTurnThinking(s.thinking);
|
||
addProcessEntry('think', s.thinking);
|
||
}
|
||
|
||
// Format violation
|
||
if (s.isFormatViolation) {
|
||
addProcessEntry('warn', 'Format violation — retrying');
|
||
break;
|
||
}
|
||
|
||
// Tool call
|
||
if (s.action && !s.toolResult) {
|
||
sawToolActivityThisTurn = true;
|
||
addProcessEntry('tool', `${s.action} ${JSON.stringify(s.params || {}).slice(0, 100)}`);
|
||
if (s.thought) addProcessEntry('info', s.thought);
|
||
}
|
||
|
||
// Tool result (same step object updated)
|
||
if (s.action && s.toolResult) {
|
||
sawToolActivityThisTurn = true;
|
||
const toolText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
|
||
const isErr = toolText.startsWith('ERROR');
|
||
addProcessEntry(isErr ? 'error' : 'result', toolText);
|
||
if (s.action === 'web_search') {
|
||
const diag = s?.toolData?.search_diagnostics || s?.diagnostics || null;
|
||
if (diag) {
|
||
const query = String(diag.query || s?.params?.query || '').trim();
|
||
if (query) addProcessEntry('info', `Search query: ${query}`);
|
||
if (Array.isArray(diag.attempted) && diag.attempted.length) {
|
||
const providers = diag.attempted.map(a => {
|
||
const p = String(a.provider || '').toLowerCase();
|
||
const status = String(a.status || '').toLowerCase();
|
||
if (status === 'success') {
|
||
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
|
||
return `${p}=success${count}`;
|
||
}
|
||
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
|
||
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
|
||
}).join(' | ');
|
||
addProcessEntry('info', `Providers: ${providers}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final answer
|
||
if (s.finalAnswer) {
|
||
addProcessEntry('final', s.finalAnswer);
|
||
partialContent = s.finalAnswer; // track for stop
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'memory_suggest':
|
||
// event.suggestion: { fact, reference, source_tool, source_output, actor }
|
||
const s = event.suggestion || {};
|
||
addProcessEntry('memory', s.fact || '(memory suggestion)', s);
|
||
addProcessEntry('info', 'A memory suggestion was created; approve to persist.');
|
||
break;
|
||
|
||
case 'memory_saved':
|
||
addProcessEntry(event.ok ? 'result' : 'warn', event.ok
|
||
? `Memory updated${event.key ? ` (${event.key})` : ''}.`
|
||
: 'Memory update failed.');
|
||
break;
|
||
|
||
case 'web_search_snippets':
|
||
// event: { query, snippets }
|
||
const q = event.query || '(search)';
|
||
addProcessEntry('info', `Search results: ${q}`, event.snippets || []);
|
||
if (event.diagnostics && Array.isArray(event.diagnostics.attempted)) {
|
||
const providers = event.diagnostics.attempted.map(a => {
|
||
const p = String(a.provider || '').toLowerCase();
|
||
const status = String(a.status || '').toLowerCase();
|
||
if (status === 'success') {
|
||
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
|
||
return `${p}=success${count}`;
|
||
}
|
||
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
|
||
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
|
||
}).join(' | ');
|
||
addProcessEntry('info', `Providers: ${providers}`);
|
||
}
|
||
break;
|
||
|
||
case 'image':
|
||
if (event.url) {
|
||
pushProgressLine(`Image: ${event.alt || event.url}`);
|
||
const imgAlt = String(event.alt || '').replace(/"/g, '"');
|
||
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, ''').replace(/</g, '<');
|
||
const imgOnerr = `this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class="md-img-err">✖ ${imgErrAlt}를 불러올 수 없습니다.</span>')`;
|
||
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" onerror="${imgOnerr}" />`;
|
||
// Append image to current streaming AI message
|
||
const lastAiMsg = chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'ai'
|
||
? chatHistory[chatHistory.length - 1] : null;
|
||
if (lastAiMsg) {
|
||
lastAiMsg.content += '\n\n' + imgHtml;
|
||
} else {
|
||
chatHistory.push({ role: 'ai', content: imgHtml });
|
||
}
|
||
renderChatMessages();
|
||
}
|
||
break;
|
||
|
||
case 'files':
|
||
if (Array.isArray(event.links)) {
|
||
for (const lnk of event.links) turnFileLinks.push(lnk);
|
||
}
|
||
break;
|
||
|
||
case 'error':
|
||
addProcessEntry('error', event.message);
|
||
break;
|
||
|
||
case 'done':
|
||
finalReply = event.reply || '';
|
||
if (finalReply) partialContent = finalReply;
|
||
finalArtifacts = Array.isArray(event.artifacts) ? event.artifacts : [];
|
||
loadAgentSessionContext();
|
||
renderLiveStepsPanel();
|
||
break;
|
||
|
||
case 'turn_execution_created':
|
||
case 'turn_execution_updated':
|
||
loadAgentSessionContext();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (finalReply) {
|
||
// Append file cards for any attachments sent via the 'files' SSE event
|
||
if (turnFileLinks.length > 0) {
|
||
const seen = new Set();
|
||
const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; });
|
||
// Only append links not already present as markdown in the reply
|
||
const toAppend = newLinks.filter(l => !finalReply.includes(l.url || ''));
|
||
if (toAppend.length > 0) {
|
||
finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n');
|
||
}
|
||
}
|
||
const finalStep = allSteps.find(s => s.finalAnswer);
|
||
const mergedThinking = turnThinkingBuffer.join('\n\n').trim();
|
||
const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn;
|
||
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
|
||
chatHistory.push({
|
||
role: 'ai',
|
||
content: finalReply,
|
||
artifacts: finalArtifacts,
|
||
steps: allSteps,
|
||
mode: useAgentMode ? 'agentic' : 'chat',
|
||
thinking: shouldAttachThinkingPanel ? (mergedThinking || finalStep?.thinking) : '',
|
||
processEntries: turnEntries
|
||
});
|
||
} else {
|
||
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
|
||
chatHistory.push({ role: 'ai', content: 'No response received.', processEntries: turnEntries });
|
||
}
|
||
persistActiveChat();
|
||
|
||
// PPTX: detect pptx-outline block in final reply
|
||
if (finalReply) {
|
||
const outlineMatch = finalReply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
|
||
if (outlineMatch) {
|
||
try { pptxSetOutline(JSON.parse(outlineMatch[1])); } catch {}
|
||
}
|
||
}
|
||
|
||
// Auto-open code blocks from final reply in the editor
|
||
if (finalReply && typeof codeAiRenderMarkdown === 'function') {
|
||
const codeBlockRe = /```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```/g;
|
||
let cbm;
|
||
while ((cbm = codeBlockRe.exec(finalReply)) !== null) {
|
||
const lang = cbm[1] || 'text';
|
||
const code = cbm[2];
|
||
if (codeIsOpenable(code, lang)) {
|
||
codeAiOpenInEditor(code, lang);
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (err) {
|
||
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
|
||
if (err.name === 'AbortError') {
|
||
addProcessEntry('warn', 'Generation stopped by user.');
|
||
const content = partialContent ||
|
||
(allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]');
|
||
chatHistory.push({ role: 'ai', content, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', processEntries: turnEntries });
|
||
} else {
|
||
lastHeartbeat = { ...lastHeartbeat, state: 'stalled', level: 'hard', message: String(err.message || 'connection_error'), current_step: 'error' };
|
||
updateHeartbeatUI();
|
||
addProcessEntry('error', err.message);
|
||
chatHistory.push({ role: 'ai', content: `Connection error: ${err.message}`, processEntries: turnEntries });
|
||
}
|
||
persistActiveChat();
|
||
}
|
||
|
||
isThinking = false;
|
||
if (typeof codeMonacoThrottleFlush === 'function') codeMonacoThrottleFlush();
|
||
if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }
|
||
streamingSessionId = null; // release session lock
|
||
currentAbortController = null;
|
||
setButtonState(false);
|
||
currentPreflightStatus = '';
|
||
currentProgressLines = [];
|
||
lastHeartbeat = { ...lastHeartbeat, state: 'idle', level: '', message: '', current_step: 'done' };
|
||
updateHeartbeatUI();
|
||
currentTurnStartIndex = -1;
|
||
sendBtn.disabled = false;
|
||
renderChatMessages();
|
||
updateQueuedPromptUI();
|
||
|
||
const shouldPauseQueue = isFailedTurnReply(finalReply || (chatHistory[chatHistory.length - 1]?.content || ''));
|
||
if (queuedPrompts.length > 0 && shouldPauseQueue) {
|
||
addProcessEntry('warn', 'Queue paused because the previous turn failed/blocked. Press Send to resume queued prompts.');
|
||
} else if (queuedPrompts.length > 0) {
|
||
const next = queuedPrompts.shift();
|
||
updateQueuedPromptUI();
|
||
addProcessEntry('info', `Auto-running queued prompt${queuedPrompts.length ? ` (${queuedPrompts.length} remaining)` : ''}.`);
|
||
setTimeout(() => { sendChat(next); }, 0);
|
||
}
|
||
}
|
||
|
||
// ---- Agent Execution Tracking ----
|
||
function spawnAgentExecution(agentName, taskPrompt, isSubagent = false) {
|
||
const agentId = (isSubagent ? 'sub_' : 'main_') + (++agentIdCounter);
|
||
const agentData = {
|
||
id: agentId,
|
||
name: agentName,
|
||
status: AGENT_STATUS.ACTIVE,
|
||
task: taskPrompt,
|
||
logs: [],
|
||
isSubagent: isSubagent,
|
||
startTime: new Date(),
|
||
};
|
||
agentExecutionMap.set(agentId, agentData);
|
||
renderAgentExecutionPanel();
|
||
return agentId;
|
||
}
|
||
|
||
function addAgentLog(agentId, type, content) {
|
||
const agentData = agentExecutionMap.get(agentId);
|
||
if (!agentData) return;
|
||
const ts = new Date().toLocaleTimeString();
|
||
agentData.logs.push({ ts, type, content });
|
||
// Keep only last 20 logs per agent
|
||
if (agentData.logs.length > 20) agentData.logs.shift();
|
||
renderAgentExecutionPanel();
|
||
}
|
||
|
||
function completeAgentExecution(agentId) {
|
||
const agentData = agentExecutionMap.get(agentId);
|
||
if (!agentData) return;
|
||
agentData.status = AGENT_STATUS.COMPLETED;
|
||
renderAgentExecutionPanel();
|
||
}
|
||
|
||
function pauseAgentExecution(agentId, reason) {
|
||
const agentData = agentExecutionMap.get(agentId);
|
||
if (!agentData) return;
|
||
agentData.status = AGENT_STATUS.PAUSED;
|
||
if (reason) addAgentLog(agentId, 'error', reason);
|
||
renderAgentExecutionPanel();
|
||
}
|
||
|
||
function renderAgentExecutionPanel() {
|
||
const panel = document.getElementById('agent-execution-panel');
|
||
if (!panel) return;
|
||
|
||
if (agentExecutionMap.size === 0) {
|
||
panel.innerHTML = '<div style="padding:8px 12px;font-size:11px;color:var(--muted)">작업 실행 대기 중...</div>';
|
||
return;
|
||
}
|
||
|
||
const agents = Array.from(agentExecutionMap.values()).sort((a, b) => a.startTime - b.startTime);
|
||
panel.innerHTML = agents.map((agent) => {
|
||
const statusClass = agent.status === AGENT_STATUS.ACTIVE ? 'active' :
|
||
agent.status === AGENT_STATUS.COMPLETED ? 'completed' : 'paused';
|
||
const arrow = agent.expanded ? '▼' : '▶';
|
||
const logsHtml = agent.logs.length > 0
|
||
? agent.logs.map(log => `<div class="agent-log-entry"><span class="agent-log-entry-time">${escHtml(log.ts)}</span><span class="agent-log-entry-type">[${escHtml(log.type.toUpperCase())}]</span><span class="agent-log-entry-content">${escHtml(log.content)}</span></div>`).join('')
|
||
: '<div style="color:var(--muted);opacity:0.6">No logs yet</div>';
|
||
|
||
return `
|
||
<div class="agent-item ${agent.expanded ? 'expanded' : ''}" onclick="toggleAgentPanel('${agent.id}')">
|
||
<div class="agent-item-header">
|
||
<span style="color:var(--muted);font-size:10px;min-width:12px">${arrow}</span>
|
||
<div class="agent-item-name">${escHtml(agent.isSubagent ? '└─ ' + agent.name : agent.name)}</div>
|
||
<div class="agent-item-status ${statusClass}">${agent.status.toUpperCase()}</div>
|
||
</div>
|
||
<div class="agent-item-task">${escHtml(agent.task.substring(0, 70))}${agent.task.length > 70 ? '...' : ''}</div>
|
||
<div class="agent-item-logs">${logsHtml}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleAgentPanel(agentId) {
|
||
const agentData = agentExecutionMap.get(agentId);
|
||
if (!agentData) return;
|
||
agentData.expanded = !agentData.expanded;
|
||
renderAgentExecutionPanel();
|
||
}
|
||
|
||
function openProcessLogFile() {
|
||
// Call server endpoint to open the process log file
|
||
if (activeTaskId) {
|
||
fetch(`/api/open-file?task=${encodeURIComponent(activeTaskId)}&type=process`, {
|
||
headers: authHeaders()
|
||
})
|
||
.catch(err => console.error('Unable to open process log:', err));
|
||
}
|
||
}
|
||
|
||
function clearChat() {
|
||
chatHistory = [];
|
||
persistActiveChat();
|
||
renderChatMessages();
|
||
}
|
||
|
||
function toggleExportMenu(e) {
|
||
if (e) e.stopPropagation();
|
||
var menu = document.getElementById('export-menu');
|
||
if (!menu) return;
|
||
var opening = menu.style.display === 'none' || !menu.style.display;
|
||
menu.style.display = opening ? 'block' : 'none';
|
||
if (opening) {
|
||
setTimeout(function() {
|
||
document.addEventListener('click', function closeExport() {
|
||
menu.style.display = 'none';
|
||
document.removeEventListener('click', closeExport);
|
||
});
|
||
}, 0);
|
||
}
|
||
}
|
||
|
||
function exportChat(format) {
|
||
var menu = document.getElementById('export-menu');
|
||
if (menu) menu.style.display = 'none';
|
||
|
||
if (!chatHistory || !chatHistory.length) {
|
||
alert('내보낼 채팅 내용이 없습니다.');
|
||
return;
|
||
}
|
||
|
||
var sess = chatSessions.find(function(s) { return s.id === activeChatSessionId; });
|
||
var title = (sess && sess.title) ? sess.title : '채팅';
|
||
var dateStr = new Date().toLocaleString('ko-KR');
|
||
var safeName = title.replace(/[^\w가-힣\s-]/g, '').trim().replace(/\s+/g, '_').slice(0, 40) || 'chat';
|
||
var dateSuffix = new Date().toISOString().slice(0, 10);
|
||
var filename = safeName + '_' + dateSuffix;
|
||
|
||
var content, mime, ext;
|
||
|
||
if (format === 'json') {
|
||
content = JSON.stringify({
|
||
title: title,
|
||
exportedAt: new Date().toISOString(),
|
||
sessionId: activeChatSessionId,
|
||
messages: chatHistory.map(function(m) {
|
||
return { role: m.role, content: (m.display || m.content || '').trim() };
|
||
})
|
||
}, null, 2);
|
||
mime = 'application/json';
|
||
ext = 'json';
|
||
|
||
} else if (format === 'md') {
|
||
var lines = ['# ' + title, '', '> 내보낸 시간: ' + dateStr, ''];
|
||
chatHistory.forEach(function(m) {
|
||
var label = m.role === 'user' ? '## 사용자' : '## 어시스턴트';
|
||
var text = (m.display || m.content || '').trim();
|
||
lines.push(label, '', text, '', '---', '');
|
||
});
|
||
content = lines.join('\n');
|
||
mime = 'text/markdown';
|
||
ext = 'md';
|
||
|
||
} else {
|
||
var lines = [title, '내보낸 시간: ' + dateStr, '='.repeat(50), ''];
|
||
chatHistory.forEach(function(m) {
|
||
var label = m.role === 'user' ? '[사용자]' : '[AI]';
|
||
var text = (m.display || m.content || '').trim()
|
||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '[이미지]')
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||
.replace(/[*_`#>]/g, '');
|
||
lines.push(label, text, '');
|
||
});
|
||
content = lines.join('\n');
|
||
mime = 'text/plain';
|
||
ext = 'txt';
|
||
}
|
||
|
||
var blob = new Blob([content], { type: mime + '; charset=utf-8' });
|
||
var a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = filename + '.' + ext;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||
}
|
||
|
||
// Enter to send, Shift+Enter for newline
|
||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendChat();
|
||
}
|
||
});
|
||
|
||
// Auto-resize textarea
|
||
document.getElementById('chat-input').addEventListener('input', function() {
|
||
this.style.height = 'auto';
|
||
this.style.height = Math.min(this.scrollHeight, 140) + 'px';
|
||
});
|
||
|
||
// ---- WebSocket ----
|
||
function connectWS() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const token = getAuthToken();
|
||
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
|
||
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws${qs}`);
|
||
ws.onmessage = (e) => {
|
||
const msg = JSON.parse(e.data);
|
||
if (msg.type === 'jobs_update') {
|
||
allJobs = msg.jobs;
|
||
updateStats([]);
|
||
if (selectedJobId) refreshJobDetail(selectedJobId);
|
||
}
|
||
if (msg.type === 'job_created') {
|
||
log(`Job created: ${msg.jobId}`, 'info');
|
||
}
|
||
if (msg.type === 'boot_greeting') {
|
||
// Show boot greeting in current session regardless of existing history
|
||
const bootSess = chatSessions.find(s => s.id === activeChatSessionId);
|
||
if (bootSess) {
|
||
if (!bootSess.history) bootSess.history = [];
|
||
bootSess.history.push({ role: 'assistant', content: msg.text });
|
||
bootSess.updatedAt = Date.now();
|
||
saveChatSessions();
|
||
syncActiveChat();
|
||
renderChatMessages();
|
||
}
|
||
}
|
||
// -- Cron/Heartbeat task events --
|
||
if (msg.type === 'tasks_update') {
|
||
hbJobs = msg.jobs || [];
|
||
if (msg.config) hbConfig = msg.config;
|
||
if (currentMode === 'heartbeat') renderHeartbeatPage();
|
||
}
|
||
if (msg.type === 'task_running') {
|
||
const job = hbJobs.find(j => j.id === msg.jobId);
|
||
if (job) job.status = 'running';
|
||
if (currentMode === 'heartbeat') renderHeartbeatPage();
|
||
addProcessEntry('info', `Task running: ${msg.jobName}`);
|
||
}
|
||
if (msg.type === 'task_done') {
|
||
hbJobs = msg.jobs || hbJobs;
|
||
if (msg.config) hbConfig = msg.config;
|
||
if (currentMode === 'heartbeat') renderHeartbeatPage();
|
||
if (msg.automatedSession && !msg.isOk) {
|
||
// Add the automated session to chatSessions so it appears in the sidebar
|
||
const as = msg.automatedSession;
|
||
const existingIdx = chatSessions.findIndex(s => s.id === as.id);
|
||
const newSession = {
|
||
id: as.id, title: as.title,
|
||
history: as.history.map(m => ({ role: m.role === 'ai' ? 'assistant' : m.role, content: m.content })),
|
||
processLog: [], createdAt: as.createdAt, updatedAt: Date.now(),
|
||
automated: true, jobName: as.jobName, unread: true,
|
||
};
|
||
if (existingIdx === -1) chatSessions.push(newSession);
|
||
else chatSessions[existingIdx] = { ...chatSessions[existingIdx], ...newSession };
|
||
saveChatSessions();
|
||
renderSessionsList();
|
||
bgtToast('\uD83D\uDD50 Scheduled task done', `"${msg.jobName}" — results in sidebar`);
|
||
addProcessEntry('info', `Task "${msg.jobName}" completed — check sidebar for results`);
|
||
} else if (msg.isOk) {
|
||
addProcessEntry('info', `Task "${msg.jobName}" — OK (nothing to report)`);
|
||
}
|
||
}
|
||
// Cron job spawned a background task — switch to tasks view if currently watching
|
||
if (msg.type === 'cron_task_spawned') {
|
||
bgtToast('\uD83D\uDD50 Scheduled job running', `"${msg.jobName}" started as background task`);
|
||
if (currentMode === 'bgtasks') refreshBgTasks();
|
||
}
|
||
if (msg.type === 'task_sse') {
|
||
// Live progress from a running task — could update the NOW card
|
||
if (currentMode === 'heartbeat' && msg.event === 'tool_result') {
|
||
const card = document.querySelector(`[data-job-id="${msg.jobId}"] .hb-card-meta`);
|
||
if (card) card.innerHTML = `<span style="color:var(--brand)">Working: ${escHtml((msg.data?.action || '').slice(0,40))}...</span>`;
|
||
}
|
||
}
|
||
// -- Background Task (Kanban) events --
|
||
if (msg.type === 'task_running' || msg.type === 'task_complete' || msg.type === 'task_paused' ||
|
||
msg.type === 'task_failed' || msg.type === 'task_needs_assistance' || msg.type === 'task_step_done' || msg.type === 'task_tool_call' ||
|
||
msg.type === 'task_heartbeat_tick' || msg.type === 'task_heartbeat_resumed') {
|
||
// Live-refresh the kanban board if visible
|
||
if (currentMode === 'bgtasks') refreshBgTasks();
|
||
// If panel is open for this task, refresh it
|
||
if (bgtOpenTaskId && (msg.taskId === bgtOpenTaskId)) {
|
||
bgtRefreshOpenPanel();
|
||
}
|
||
// Toast notification for completions and errors
|
||
if (msg.type === 'task_complete') {
|
||
bgtToast(`\u2713 Task complete`, msg.summary ? msg.summary.slice(0,120) : '');
|
||
// If this task has a cron session ID (starts with cron_), create/update a sidebar session
|
||
// so the results are immediately visible without a page refresh.
|
||
if (msg.taskId) {
|
||
(async () => {
|
||
try {
|
||
const data = await fetch(`/api/bg-tasks/${msg.taskId}`, {
|
||
headers: authHeaders()
|
||
}).then(r => r.json());
|
||
const task = data?.task || data;
|
||
if (task && task.sessionId && task.sessionId.startsWith('cron_')) {
|
||
const journal = task.journal || [];
|
||
const summaryEntry = journal.filter(e => e.type === 'summary' || e.type === 'complete').pop();
|
||
const summaryText = msg.summary || (summaryEntry?.content) || '(Task complete — open Tasks panel for details)';
|
||
const titleText = `\uD83D\uDD50 ${task.title || 'Scheduled Task'} \u2014 ${new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`;
|
||
const sessionId = `auto_${task.id}_${Date.now()}`;
|
||
const existingIdx = chatSessions.findIndex(s => s.id === task.sessionId || (s.jobName && s.automated && s.title === titleText));
|
||
const newSession = {
|
||
id: sessionId, title: titleText,
|
||
history: [
|
||
{ role: 'user', content: `[Scheduled Task: ${task.title}]\n\n${task.prompt || ''}` },
|
||
{ role: 'assistant', content: summaryText },
|
||
],
|
||
processLog: [], createdAt: Date.now(), updatedAt: Date.now(),
|
||
automated: true, jobName: task.title, unread: true,
|
||
};
|
||
if (existingIdx === -1) chatSessions.push(newSession);
|
||
saveChatSessions();
|
||
renderSessionsList();
|
||
}
|
||
} catch {}
|
||
})();
|
||
}
|
||
} else if (msg.type === 'task_failed') {
|
||
bgtToast(`\u2715 Task failed`, msg.error ? msg.error.slice(0,120) : '');
|
||
} else if (msg.type === 'task_needs_assistance') {
|
||
bgtToast(`Task paused`, msg.reason ? String(msg.reason).slice(0, 120) : 'Needs assistance');
|
||
}
|
||
}
|
||
// Panel-only refresh (frequent tool_call updates — only refresh open panel, not full kanban)
|
||
if (msg.type === 'task_panel_update') {
|
||
if (bgtOpenTaskId && msg.taskId === bgtOpenTaskId) {
|
||
bgtRefreshOpenPanel();
|
||
}
|
||
}
|
||
// Notification from task runner (status push to channel)
|
||
if (msg.type === 'task_notification' && msg.sessionId) {
|
||
// Try to add a status message to the originating chat session
|
||
const sess = chatSessions.find(s => s.id === msg.sessionId);
|
||
if (sess) {
|
||
sess.history = sess.history || [];
|
||
sess.history.push({ role: 'assistant', content: msg.message || '' });
|
||
saveChatSessions();
|
||
if (currentSessionId === msg.sessionId) renderMessages();
|
||
}
|
||
}
|
||
// ---- Agent Execution Events ----
|
||
if (msg.type === 'agent_spawned') {
|
||
const agentId = spawnAgentExecution(msg.name || 'Subagent', msg.task || '', msg.isSubagent);
|
||
// Store mapping for WebSocket message routing
|
||
if (msg.serverAgentId) {
|
||
if (!window.agentIdMap) window.agentIdMap = {};
|
||
window.agentIdMap[msg.serverAgentId] = agentId;
|
||
}
|
||
}
|
||
if (msg.type === 'agent_log') {
|
||
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
|
||
if (agentId) addAgentLog(agentId, msg.logType || 'info', msg.content);
|
||
}
|
||
if (msg.type === 'agent_completed') {
|
||
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
|
||
if (agentId) completeAgentExecution(agentId);
|
||
}
|
||
if (msg.type === 'agent_paused') {
|
||
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
|
||
if (agentId) pauseAgentExecution(agentId, msg.reason);
|
||
}
|
||
};
|
||
ws.onclose = () => setTimeout(connectWS, 2000);
|
||
ws.onerror = () => ws.close();
|
||
}
|
||
|
||
// ---- API ----
|
||
async function api(path, opts = {}) {
|
||
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
|
||
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
|
||
const r = await fetch(API + path, { headers, ...restOpts });
|
||
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
|
||
const ct = String(r.headers.get('content-type') || '').toLowerCase();
|
||
const bodyText = await r.text();
|
||
|
||
if (!ct.includes('application/json')) {
|
||
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
|
||
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
|
||
}
|
||
|
||
let data = null;
|
||
try {
|
||
data = bodyText ? JSON.parse(bodyText) : null;
|
||
} catch {
|
||
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
|
||
throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`);
|
||
}
|
||
|
||
if (!r.ok) {
|
||
const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`;
|
||
throw new Error(msg);
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
const LAST_USER_KEY = 'cherryclaw_last_user';
|
||
|
||
function clearLocalUserState(reason) {
|
||
try {
|
||
localStorage.removeItem(chatSessionsKey());
|
||
localStorage.removeItem(agentSessionKey());
|
||
} catch {}
|
||
try {
|
||
chatSessions = [];
|
||
activeChatSessionId = null;
|
||
} catch {}
|
||
setAgentSessionId(generateSessionId());
|
||
// Rebuild a fresh "New chat" + re-render so the UI does not briefly
|
||
// display the previous user's session list before the wipe takes effect.
|
||
try { if (typeof loadChatSessions === 'function') loadChatSessions(); } catch {}
|
||
try { if (typeof renderSessionsList === 'function') renderSessionsList(); } catch {}
|
||
try { if (typeof renderMessages === 'function') renderMessages(); } catch {}
|
||
console.log(`[auth] cleared local session state (${reason})`);
|
||
}
|
||
|
||
async function checkAuth() {
|
||
try {
|
||
const res = await fetch('/api/auth/status', { headers: authHeaders() });
|
||
const data = await res.json();
|
||
if (data.enabled && !data.authenticated && !data.hasPassword) {
|
||
window.location.href = '/login.html';
|
||
return;
|
||
}
|
||
if (data.enabled && !data.authenticated) {
|
||
window.location.href = '/login.html';
|
||
return;
|
||
}
|
||
if (data.authenticated && data.username) {
|
||
currentUser = { username: data.username, role: data.role };
|
||
|
||
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
|
||
// This also migrates any data from the old non-namespaced key on first load after server restart.
|
||
if (typeof loadChatSessions === 'function') loadChatSessions();
|
||
|
||
// Multi-user safety: if a different user was logged in on this browser
|
||
// before, wipe the cached session list & active sessionId so the
|
||
// incoming user does not reuse the previous user's chat IDs.
|
||
const lastUser = (localStorage.getItem(LAST_USER_KEY) || '').trim();
|
||
if (lastUser && lastUser !== data.username) {
|
||
clearLocalUserState(`user changed: ${lastUser} -> ${data.username}`);
|
||
}
|
||
try { localStorage.setItem(LAST_USER_KEY, data.username); } catch {}
|
||
|
||
// Show user pill with username
|
||
const pill = document.getElementById('user-info-pill');
|
||
const nameEl = document.getElementById('user-info-name');
|
||
if (pill) pill.style.display = 'flex';
|
||
if (nameEl) nameEl.textContent = data.username;
|
||
const mobileLogout = document.getElementById('mob-tab-logout');
|
||
if (mobileLogout) mobileLogout.style.display = 'flex';
|
||
}
|
||
} catch (e) {
|
||
console.error('Auth check failed:', e);
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() });
|
||
} catch (e) { /* ignore network errors */ }
|
||
// Wipe cached chat list / sessionId so a different user logging in next
|
||
// does not inherit any of this user's session IDs.
|
||
clearLocalUserState('logout');
|
||
try { localStorage.removeItem(LAST_USER_KEY); } catch {}
|
||
setAuthToken('');
|
||
window.location.href = '/login.html';
|
||
}
|
||
|
||
async function loadSearchSettingsSummary() {
|
||
try {
|
||
const s = await api('/api/settings/search');
|
||
const el = document.getElementById('r-failed');
|
||
if (el) el.textContent = s.preferred_provider || 'ddg';
|
||
quickSearchRigor = s.search_rigor || 'verified';
|
||
updateQuickModeUI();
|
||
} catch {}
|
||
}
|
||
|
||
function updateQuickModeUI() {
|
||
const lbl = document.getElementById('quick-mode-label');
|
||
if (lbl) lbl.textContent = (quickSearchRigor || 'verified').replace(/^./, c => c.toUpperCase());
|
||
['fast', 'verified', 'strict'].forEach(v => {
|
||
const el = document.getElementById(`rigor-${v}`);
|
||
if (el) el.classList.toggle('active', quickSearchRigor === v);
|
||
});
|
||
['standard', 'extended'].forEach(v => {
|
||
const el = document.getElementById(`think-${v}`);
|
||
if (el) el.classList.toggle('active', quickThinkingEffort === v);
|
||
});
|
||
}
|
||
|
||
function toggleQuickModePopover() {
|
||
const pop = document.getElementById('quick-mode-popover');
|
||
if (!pop) return;
|
||
const nextOpen = !pop.classList.contains('open');
|
||
pop.classList.toggle('open', nextOpen);
|
||
if (nextOpen) {
|
||
pop.classList.remove('clamp-left');
|
||
requestAnimationFrame(() => {
|
||
const rect = pop.getBoundingClientRect();
|
||
if (rect.left < 8) pop.classList.add('clamp-left');
|
||
const after = pop.getBoundingClientRect();
|
||
if (after.top < 8) {
|
||
pop.style.bottom = 'auto';
|
||
pop.style.top = '34px';
|
||
} else {
|
||
pop.style.top = '';
|
||
pop.style.bottom = '34px';
|
||
}
|
||
});
|
||
}
|
||
updateQuickModeUI();
|
||
}
|
||
|
||
function toggleOllamaModelRow(provider) {
|
||
const row = document.getElementById('search-ollama-model-row');
|
||
if (row) row.style.display = provider === 'ollama' ? 'block' : 'none';
|
||
}
|
||
|
||
function toggleSearxngUrlRow(provider) {
|
||
const row = document.getElementById('search-searxng-url-row');
|
||
if (row) row.style.display = provider === 'searxng' ? 'block' : 'none';
|
||
}
|
||
|
||
async function setQuickSearchRigor(level) {
|
||
quickSearchRigor = level;
|
||
updateQuickModeUI();
|
||
try {
|
||
const s = await api('/api/settings/search');
|
||
const payload = {
|
||
preferred_provider: s.preferred_provider || 'ddg',
|
||
search_rigor: level,
|
||
tavily_api_key: s.tavily_api_key || '',
|
||
google_api_key: s.google_api_key || '',
|
||
google_cx: s.google_cx || '',
|
||
brave_api_key: s.brave_api_key || '',
|
||
};
|
||
await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) });
|
||
addProcessEntry('info', `Search rigor set to ${level}.`);
|
||
await loadSearchSettingsSummary();
|
||
} catch (err) {
|
||
addProcessEntry('error', `Failed to set search rigor: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function setQuickThinkingEffort(level) {
|
||
quickThinkingEffort = level === 'extended' ? 'extended' : 'standard';
|
||
localStorage.setItem('cherryclaw_quick_thinking_effort', quickThinkingEffort);
|
||
updateQuickModeUI();
|
||
addProcessEntry('info', `Thinking effort set to ${quickThinkingEffort} (UI preference).`);
|
||
}
|
||
|
||
function setSettingsTab(tab) {
|
||
settingsTab = tab;
|
||
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'voice', 'session', 'context-viewer'];
|
||
tabs.forEach(t => {
|
||
const btn = document.getElementById(`settings-tab-${t}`);
|
||
const panel = document.getElementById(`settings-panel-${t}`);
|
||
if (btn) {
|
||
btn.style.background = (t === tab) ? '#eaf2ff' : '#fff';
|
||
btn.style.borderColor = (t === tab) ? '#bdd3f6' : 'var(--line)';
|
||
btn.style.color = (t === tab) ? '#0d4faf' : 'var(--muted)';
|
||
}
|
||
if (panel) {
|
||
if (t === tab) {
|
||
panel.style.display = 'block';
|
||
if (t === 'channels') loadChannelsStatus();
|
||
if (t === 'models') loadModelSettings();
|
||
if (t === 'agents') loadAgentsTab();
|
||
if (t === 'integrations') loadIntegrationsTab();
|
||
if (t === 'credentials') loadCredentialsTab();
|
||
if (t === 'users') loadUsersTab();
|
||
if (t === 'voice') loadVoiceSettings();
|
||
if (t === 'session') loadSessionSettings();
|
||
if (t === 'context-viewer') loadContextViewerTab();
|
||
} else {
|
||
panel.style.display = 'none';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Credentials Tab ---------------------------------------------------------
|
||
|
||
async function loadCredentialsTab() {
|
||
await Promise.all([loadCredFields(), loadCredVaultStatus(), loadCredVaultLog()]);
|
||
}
|
||
|
||
async function loadCredFields() {
|
||
try {
|
||
const s = await api('/api/settings/search');
|
||
// Server returns '••••••••' if key is set, '' if not
|
||
const setField = (id, val) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.value = val || '';
|
||
el.placeholder = val ? '•••••••• (key stored — enter new value to replace)' : el.getAttribute('data-placeholder') || '';
|
||
};
|
||
setField('cred-tavily-key', s.tavily_api_key);
|
||
setField('cred-google-key', s.google_api_key);
|
||
setField('cred-brave-key', s.brave_api_key);
|
||
const cxEl = document.getElementById('cred-google-cx');
|
||
if (cxEl) cxEl.value = s.google_cx || '';
|
||
} catch(e) {
|
||
console.warn('loadCredFields:', e);
|
||
}
|
||
// OpenWeather key is stored separately
|
||
try {
|
||
const ow = await api('/api/settings/openweather');
|
||
const owEl = document.getElementById('cred-ow-key');
|
||
if (owEl) {
|
||
owEl.value = ow.api_key || '';
|
||
owEl.placeholder = ow.api_key ? '•••••••• (key stored — enter new value to replace)' : '';
|
||
}
|
||
} catch(e) {
|
||
console.warn('loadCredFields openweather:', e);
|
||
}
|
||
}
|
||
|
||
async function loadCredVaultStatus() {
|
||
const el = document.getElementById('cred-vault-status');
|
||
if (!el) return;
|
||
try {
|
||
const data = await api('/api/credentials/status');
|
||
const keys = data.keys || [];
|
||
if (!keys.length) {
|
||
el.innerHTML = '<span style="color:var(--warn)">⚠ No credentials stored yet.</span>';
|
||
return;
|
||
}
|
||
el.innerHTML = keys.map(k => {
|
||
const label = {
|
||
'search.tavily_api_key': 'Tavily API Key',
|
||
'search.google_api_key': 'Google API Key',
|
||
'search.brave_api_key': 'Brave API Key',
|
||
'llm.openai.api_key': 'OpenAI API Key',
|
||
'hooks.token': 'Webhook Token',
|
||
'channels.telegram.botToken': 'Telegram Token',
|
||
'channels.discord.botToken': 'Discord Token',
|
||
'openweather.api_key': 'OpenWeather API Key',
|
||
}[k] || k;
|
||
return `<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px">
|
||
<span style="color:var(--ok);font-size:13px">✓</span>
|
||
<span style="font-size:12px;color:var(--text)">${label}</span>
|
||
<span style="font-size:10px;color:var(--muted);font-family:monospace">${k}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
el.innerHTML = `<span style="color:var(--err);font-size:12px">Could not load vault status: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
async function loadCredVaultLog() {
|
||
const el = document.getElementById('cred-vault-log');
|
||
if (!el) return;
|
||
try {
|
||
const data = await api('/api/credentials/audit');
|
||
const lines = (data.lines || []).slice(-18).reverse();
|
||
if (!lines.length) { el.textContent = '감사 항목 없음.'; return; }
|
||
el.innerHTML = lines.map(l => {
|
||
const safe = l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
const color = l.includes('SET') ? 'var(--ok)' : l.includes('GET') ? 'var(--brand)' : l.includes('DEL') ? 'var(--err)' : 'var(--muted)';
|
||
return `<div style="color:${color};white-space:nowrap">${safe}</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
el.textContent = '감사 로그를 불러올 수 없습니다.';
|
||
}
|
||
}
|
||
|
||
function toggleCredVis(inputId, btn) {
|
||
const el = document.getElementById(inputId);
|
||
if (!el) return;
|
||
if (el.type === 'password') {
|
||
el.type = 'text';
|
||
btn.textContent = '🙈';
|
||
// Auto-rehide after 8s
|
||
setTimeout(() => { el.type = 'password'; btn.textContent = '👁'; }, 8000);
|
||
} else {
|
||
el.type = 'password';
|
||
btn.textContent = '👁';
|
||
}
|
||
}
|
||
|
||
// --- Provider-aware Model Settings ------------------------------------------
|
||
|
||
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic'];
|
||
|
||
function onProviderChange() {
|
||
const provider = document.getElementById('settings-llm-provider').value;
|
||
// Show only the matching fields panel
|
||
PROVIDER_IDS.forEach(id => {
|
||
const el = document.getElementById('prov-fields-' + id);
|
||
if (el) el.style.display = id === provider ? 'block' : 'none';
|
||
});
|
||
if (provider === 'openai') {
|
||
refreshOpenAIModels(true).catch(() => {});
|
||
} else if (['ollama', 'lm_studio', 'llama_cpp'].includes(provider)) {
|
||
refreshProviderModels().catch(() => {});
|
||
}
|
||
syncOrchPreemptControls();
|
||
}
|
||
|
||
async function loadModelSettings() {
|
||
try {
|
||
const data = await api('/api/settings/provider');
|
||
const llm = data?.llm || { provider: 'ollama', providers: {} };
|
||
const prov = llm.provider || 'ollama';
|
||
const provSel = document.getElementById('settings-llm-provider');
|
||
if (provSel) provSel.value = prov;
|
||
// Only toggle visibility, skip model refresh here to avoid double-loading
|
||
PROVIDER_IDS.forEach(id => {
|
||
const el = document.getElementById('prov-fields-' + id);
|
||
if (el) el.style.display = id === prov ? 'block' : 'none';
|
||
});
|
||
|
||
const pc = llm.providers || {};
|
||
|
||
// Populate each provider's fields from saved config
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
|
||
v('settings-ollama-endpoint', pc.ollama?.endpoint);
|
||
v('settings-llamacpp-endpoint', pc.llama_cpp?.endpoint);
|
||
v('settings-llamacpp-model', pc.llama_cpp?.model);
|
||
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
|
||
v('settings-lmstudio-model', pc.lm_studio?.model);
|
||
v('settings-openai-key', pc.openai?.api_key);
|
||
v('settings-anthropic-key', pc.anthropic?.api_key);
|
||
if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; }
|
||
|
||
// Set primary model from saved config before refreshing the list
|
||
// Fallback to data.models_primary (models.primary) when provider's model field is empty
|
||
const savedPrimaryModel = pc[prov]?.model || data?.models_primary || '';
|
||
if (savedPrimaryModel) {
|
||
const sel = document.getElementById('settings-primary-model');
|
||
if (sel) sel.value = savedPrimaryModel;
|
||
}
|
||
|
||
if (pc.openai?.model) { const s = document.getElementById('settings-openai-model'); if (s) s.value = pc.openai.model; }
|
||
if (pc.openai_codex?.model) { const s = document.getElementById('settings-codex-model'); if (s) s.value = pc.openai_codex.model; }
|
||
|
||
// Check Codex OAuth status
|
||
await refreshCodexStatus();
|
||
|
||
// Refresh OpenAI model dropdown from current key when applicable
|
||
if (prov === 'openai') await refreshOpenAIModels(true);
|
||
|
||
// Auto-load model list for list-capable providers (preserves saved selection)
|
||
if (['ollama', 'lm_studio', 'llama_cpp'].includes(prov)) await refreshProviderModels(savedPrimaryModel);
|
||
_modelSettingsLoaded = true;
|
||
} catch (e) {
|
||
console.warn('loadModelSettings error:', e);
|
||
}
|
||
}
|
||
|
||
async function refreshProviderModels(forcedValue) {
|
||
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
|
||
const msg = document.getElementById('provider-status-msg');
|
||
if (msg) msg.textContent = '모델 로딩 중…';
|
||
try {
|
||
const llm = buildProviderPayload();
|
||
if (llm.provider !== provider) llm.provider = provider;
|
||
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
|
||
const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
|
||
const selects = ['settings-primary-model'];
|
||
selects.forEach((selId, i) => {
|
||
const sel = document.getElementById(selId);
|
||
if (!sel) return;
|
||
if (i === 0) {
|
||
// forcedValue: saved model passed from loadModelSettings (DOM value not yet set when options are empty)
|
||
const prev = forcedValue || String(sel.value || '').trim();
|
||
sel.innerHTML = models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
|
||
if (models.length) sel.value = (prev && models.includes(prev)) ? prev : models[0];
|
||
}
|
||
});
|
||
if (msg) msg.textContent = models.length ? `${models.length}개 모델 발견.` : '모델 없음 — 서버가 실행 중인가요?';
|
||
} catch (e) {
|
||
if (msg) msg.textContent = '모델 가져오기 실패.';
|
||
}
|
||
}
|
||
|
||
async function testProviderConnection() {
|
||
const provider = document.getElementById('settings-llm-provider').value;
|
||
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status', anthropic: 'provider-status-msg-anthropic' };
|
||
const msgId = statusIds[provider] || 'provider-status-msg';
|
||
const msg = document.getElementById(msgId);
|
||
if (msg) msg.textContent = '테스트 중…';
|
||
|
||
if (provider === 'openai') {
|
||
const ok = await refreshOpenAIModels(false);
|
||
if (msg) msg.textContent = ok ? '✓ 연결됨' : (msg.textContent || '✗ 연결 실패');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const llm = buildProviderPayload();
|
||
if (llm.provider !== provider) llm.provider = provider;
|
||
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
|
||
if (msg) msg.textContent = data?.success ? '✓ 연결됨' : '✗ ' + (data?.error || '연결 실패');
|
||
} catch (e) {
|
||
if (msg) msg.textContent = '✗ ' + e.message;
|
||
}
|
||
}
|
||
|
||
function updateOpenAIModelDropdown(models) {
|
||
const sel = document.getElementById('settings-openai-model');
|
||
if (!sel) return;
|
||
const current = String(sel.value || '').trim();
|
||
const unique = Array.from(new Set((models || []).filter(Boolean).map(m => String(m).trim()).filter(Boolean)));
|
||
if (!unique.length) return;
|
||
if (current && !unique.includes(current)) unique.unshift(current);
|
||
sel.innerHTML = unique.slice(0, 500).map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
async function refreshOpenAIModels(silent = false) {
|
||
const statusEl = document.getElementById('provider-status-msg-openai');
|
||
const apiKey = document.getElementById('settings-openai-key')?.value?.trim() || '';
|
||
if (!apiKey) {
|
||
if (!silent && statusEl) statusEl.textContent = 'API 키를 먼저 입력하세요.';
|
||
return false;
|
||
}
|
||
if (!silent && statusEl) statusEl.textContent = '모델 목록 가져오는 중…';
|
||
try {
|
||
const data = await api('/api/openai/models', { method: 'POST', body: JSON.stringify({ api_key: apiKey }) });
|
||
if (!data?.success) {
|
||
if (statusEl) statusEl.textContent = '✗ ' + (data?.error || '모델 가져오기 실패');
|
||
return false;
|
||
}
|
||
const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
|
||
updateOpenAIModelDropdown(models);
|
||
if (statusEl) statusEl.textContent = models.length ? `✓ ${models.length}개 모델 사용 가능` : '✓ 연결됨';
|
||
return true;
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = '✗ ' + e.message;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Build the llm config object from current UI state
|
||
function buildProviderPayload() {
|
||
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
|
||
const providers = {};
|
||
providers.ollama = { endpoint: document.getElementById('settings-ollama-endpoint')?.value || 'http://localhost:11434', model: document.getElementById('settings-primary-model')?.value || '' };
|
||
providers.llama_cpp = { endpoint: document.getElementById('settings-llamacpp-endpoint')?.value || 'http://localhost:8080', model: document.getElementById('settings-llamacpp-model')?.value || '' };
|
||
providers.lm_studio = { endpoint: document.getElementById('settings-lmstudio-endpoint')?.value || 'http://localhost:1234', model: document.getElementById('settings-lmstudio-model')?.value || '' };
|
||
providers.openai = { api_key: document.getElementById('settings-openai-key')?.value || '', model: document.getElementById('settings-openai-model')?.value || 'gpt-4o' };
|
||
providers.openai_codex = { model: document.getElementById('settings-codex-model')?.value || 'gpt-5.3-codex' };
|
||
providers.anthropic = { api_key: document.getElementById('settings-anthropic-key')?.value || '', model: document.getElementById('settings-anthropic-model')?.value || 'claude-sonnet-4-6' };
|
||
return { provider, providers };
|
||
}
|
||
|
||
// --- Codex OAuth UI ------------------------------------------------
|
||
|
||
async function refreshCodexStatus() {
|
||
try {
|
||
const data = await api('/api/auth/openai/status');
|
||
const disc = document.getElementById('codex-disconnected-state');
|
||
const conn = document.getElementById('codex-connected-state');
|
||
const acct = document.getElementById('codex-account-id');
|
||
if (data?.connected) {
|
||
if (disc) disc.style.display = 'none';
|
||
if (conn) conn.style.display = 'block';
|
||
if (acct) acct.textContent = data.account_id ? `계정: ${data.account_id}` : '계정 연결됨';
|
||
} else {
|
||
if (disc) disc.style.display = 'block';
|
||
if (conn) conn.style.display = 'none';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function startCodexOAuth() {
|
||
const statusEl = document.getElementById('codex-oauth-status');
|
||
if (statusEl) statusEl.textContent = '브라우저 열기 중…';
|
||
try {
|
||
const data = await api('/api/auth/openai/start', { method: 'POST', body: '{}' });
|
||
if (data?.needsManualPaste) {
|
||
if (statusEl) statusEl.innerHTML = 'Browser did not open. <a href="' + data.authUrl + '" target="_blank">Click here to authorize</a>, then paste the redirect URL below.<br><input id="codex-manual-url" type="text" placeholder="Paste redirect URL here…" style="width:100%;margin-top:6px;padding:6px;border:1px solid var(--line);border-radius:8px;font-size:11px"><button onclick="submitManualCodexUrl()" style="margin-top:4px;font-size:11px;padding:4px 8px;border-radius:6px;border:1px solid var(--line);cursor:pointer">Submit</button>';
|
||
} else if (data?.success) {
|
||
if (statusEl) statusEl.textContent = '';
|
||
await refreshCodexStatus();
|
||
} else {
|
||
if (statusEl) statusEl.textContent = '? ' + (data?.error || 'OAuth failed');
|
||
}
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = '✗ ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function submitManualCodexUrl() {
|
||
const url = document.getElementById('codex-manual-url')?.value?.trim();
|
||
if (!url) return;
|
||
const statusEl = document.getElementById('codex-oauth-status');
|
||
if (statusEl) statusEl.textContent = '토큰 교환 중…';
|
||
try {
|
||
const data = await api('/api/auth/openai/manual', { method: 'POST', body: JSON.stringify({ url }) });
|
||
if (data?.success) {
|
||
if (statusEl) statusEl.textContent = '';
|
||
await refreshCodexStatus();
|
||
} else {
|
||
if (statusEl) statusEl.textContent = '? ' + (data?.error || 'Failed');
|
||
}
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = '✗ ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function disconnectCodex() {
|
||
await api('/api/auth/openai/disconnect', { method: 'POST', body: '{}' });
|
||
await refreshCodexStatus();
|
||
}
|
||
|
||
// Legacy alias so any old references still work
|
||
async function refreshOllamaModels() { await refreshProviderModels(); }
|
||
|
||
async function loadAgentSessionContext() {
|
||
try {
|
||
if (!agentSessionId) setAgentSessionId(activeChatSessionId);
|
||
const sid = encodeURIComponent(agentSessionId);
|
||
const data = await api(`/api/agent/session/${sid}`);
|
||
const set = (id, val) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = val;
|
||
};
|
||
set('r-session-mode', data.mode_lock || '-');
|
||
set('r-agent-state', `${data.mode || '-'} / ${lastTurnKind !== '-' ? lastTurnKind : 'n/a'}`);
|
||
set('r-overview-objective', (data.overview_objective || '(none)').slice(0, 56));
|
||
set('r-active-objective', (data.active_objective || '(none)').slice(0, 56));
|
||
const tc = data.task_counts || {};
|
||
const tr = data.turn_counts || {};
|
||
const ec = data.execution_counts || {};
|
||
const useExecutionCounts = Number(ec.total || 0) > 0 || data.current_turn_execution;
|
||
if (useExecutionCounts) {
|
||
const doneExec = Number(ec.done || 0) + Number(ec.repaired || 0);
|
||
set('r-task-counts', `${doneExec}/${ec.total || 0} completed`);
|
||
set('r-turn-counts', `${ec.running || 0} running • ${ec.failed || 0} failed`);
|
||
} else {
|
||
set('r-task-counts', `${tc.done || 0}/${tc.total || 0} done`);
|
||
set('r-turn-counts', `${tr.completed || 0} done • ${tr.open || 0} open`);
|
||
}
|
||
const total = useExecutionCounts ? Number(ec.total || 0) : Number(tc.total || 0);
|
||
const done = useExecutionCounts ? (Number(ec.done || 0) + Number(ec.repaired || 0)) : Number(tc.done || 0);
|
||
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0;
|
||
const bar = document.getElementById('r-task-progress-bar');
|
||
if (bar) bar.style.width = `${pct}%`;
|
||
|
||
const execPanel = document.getElementById('r-turn-execution');
|
||
if (execPanel) {
|
||
const cur = data.current_turn_execution;
|
||
if (!cur || typeof cur !== 'object') {
|
||
execPanel.innerHTML = '<div style="font-size:11px;color:var(--muted)">활성 턴 실행 없음.</div>';
|
||
} else {
|
||
const objective = escHtml(String(cur.objective_raw || '(no objective)').slice(0, 220));
|
||
const status = escHtml(String(cur.status || 'planned'));
|
||
const mode = escHtml(String(cur.mode || 'unknown'));
|
||
const turnKind = escHtml(String(cur.turn_kind || 'unknown'));
|
||
const steps = Array.isArray(cur.steps) ? cur.steps : [];
|
||
const verification = cur.verification && typeof cur.verification === 'object' ? cur.verification : null;
|
||
const trace = cur.decision_trace && typeof cur.decision_trace === 'object' ? cur.decision_trace : null;
|
||
const traceEvents = Array.isArray(trace?.events) ? trace.events : [];
|
||
const stepRows = steps.map((s, idx) => {
|
||
const stepTitle = escHtml(String(s.title || s.type || `Step ${idx + 1}`));
|
||
const stepStatus = escHtml(String(s.status || 'pending'));
|
||
const outputs = s.outputs && typeof s.outputs === 'object'
|
||
? `<div class="task-meta">${escHtml(JSON.stringify(s.outputs).slice(0, 160))}</div>`
|
||
: '';
|
||
return `
|
||
<div class="task-row">
|
||
<div class="task-top">
|
||
<div class="task-title">${idx + 1}. ${stepTitle}</div>
|
||
<span class="status-chip ${stepStatus}">${stepStatus.replace('_', ' ')}</span>
|
||
</div>
|
||
${outputs}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
const verifyHtml = verification
|
||
? `<div class="task-meta">verify: <strong>${escHtml(String(verification.status || 'pass'))}</strong>${Array.isArray(verification.repairs_applied) && verification.repairs_applied.length ? ` • repairs: ${verification.repairs_applied.length}` : ''}${Array.isArray(verification.errors) && verification.errors.length ? ` • errors: ${verification.errors.length}` : ''}</div>`
|
||
: '<div class="task-meta">verify: (not run)</div>';
|
||
const traceHtml = traceEvents.length
|
||
? `
|
||
<div class="task-row">
|
||
<div class="task-top">
|
||
<div class="task-title">Decision Trace</div>
|
||
<span class="status-chip done">${traceEvents.length}</span>
|
||
</div>
|
||
${traceEvents.slice(-10).map((ev) => {
|
||
const stage = escHtml(String(ev?.stage || 'trace'));
|
||
const msg = escHtml(String(ev?.message || '').slice(0, 180));
|
||
return `<div class="task-meta">[${stage}] ${msg}</div>`;
|
||
}).join('')}
|
||
</div>
|
||
`
|
||
: '';
|
||
execPanel.innerHTML = `
|
||
<div class="task-row">
|
||
<div class="task-top">
|
||
<div class="task-title">Current Turn</div>
|
||
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
|
||
</div>
|
||
<div class="task-meta">${objective}</div>
|
||
<div class="task-meta">mode: <strong>${mode}</strong> • kind: <strong>${turnKind}</strong></div>
|
||
${verifyHtml}
|
||
</div>
|
||
${stepRows || '<div style="font-size:11px;color:var(--muted)">No execution steps.</div>'}
|
||
${traceHtml}
|
||
`;
|
||
}
|
||
}
|
||
|
||
const taskList = document.getElementById('r-task-list');
|
||
if (taskList) {
|
||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||
if (!tasks.length) {
|
||
taskList.innerHTML = '<div style="font-size:11px;color:var(--muted)">작업 없음.</div>';
|
||
} else {
|
||
taskList.innerHTML = tasks.slice(0, 20).map(t => {
|
||
const title = escHtml(String(t.title || '').slice(0, 120));
|
||
const status = escHtml(String(t.status || 'pending'));
|
||
const tool = escHtml(String(t.tool || 'tool'));
|
||
return `
|
||
<div class="task-row">
|
||
<div class="task-top">
|
||
<div class="task-title">${title}</div>
|
||
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
|
||
</div>
|
||
<div class="task-meta">tool: ${tool}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
const rt = document.getElementById('r-recent-turns');
|
||
if (rt) {
|
||
const recentExec = Array.isArray(data.recent_turn_executions) ? data.recent_turn_executions : [];
|
||
if (recentExec.length) {
|
||
rt.innerHTML = recentExec.slice(0, 10).map((e, idx) => {
|
||
const status = escHtml(String(e.status || 'planned'));
|
||
const objective = escHtml(String(e.objective_raw || '').slice(0, 120));
|
||
const tools = Array.isArray(e.tool_calls) ? e.tool_calls.length : 0;
|
||
const traceCount = Array.isArray(e?.decision_trace?.events) ? e.decision_trace.events.length : 0;
|
||
const repairs = Array.isArray(e?.verification?.repairs_applied) ? e.verification.repairs_applied.length : 0;
|
||
return `<div style="margin-bottom:6px"><span style="color:var(--muted)">#${idx + 1} [${status}]</span> ${objective || '(objective)'}<div style="font-size:10px;color:var(--muted)">tools: ${tools}${traceCount ? ` • trace: ${traceCount}` : ''}${repairs ? ` • repairs: ${repairs}` : ''}</div></div>`;
|
||
}).join('');
|
||
} else {
|
||
const turns = data.recent_turns || [];
|
||
if (!turns.length) {
|
||
rt.innerHTML = 'No turn objectives yet.';
|
||
} else {
|
||
rt.innerHTML = turns.map(t => {
|
||
const kind = escHtml(String(t.kind || 'turn'));
|
||
const status = escHtml(String(t.status || 'open'));
|
||
const text = escHtml(String(t.text || '').slice(0, 120));
|
||
return `<div style="margin-bottom:6px"><span style="color:var(--muted)">[${kind}/${status}]</span> ${text}</div>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
const toggle = document.getElementById('agent-mode-toggle');
|
||
const label = document.getElementById('agent-mode-label');
|
||
if (toggle) {
|
||
const locked = data.mode_lock === 'agent' || data.mode_lock === 'chat';
|
||
toggle.disabled = !!locked;
|
||
if (data.mode_lock === 'agent') {
|
||
toggle.checked = true;
|
||
useAgentMode = true;
|
||
} else if (data.mode_lock === 'chat') {
|
||
toggle.checked = false;
|
||
useAgentMode = false;
|
||
}
|
||
}
|
||
if (label) {
|
||
label.textContent = useAgentMode ? '에이전트' : '채팅';
|
||
}
|
||
maybeAutoScrollRightColumn(rightColumnAutoFollow && isThinking);
|
||
} catch {
|
||
const rt = document.getElementById('r-recent-turns');
|
||
if (rt) rt.innerHTML = 'Unable to load session context.';
|
||
}
|
||
}
|
||
|
||
function renderLiveStepsPanel() {
|
||
const el = document.getElementById('r-live-steps');
|
||
if (!el) return;
|
||
if (!liveTaskSteps.length) {
|
||
el.innerHTML = '<div style="font-size:11px;color:var(--muted)">활성 도구 단계 없음.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = liveTaskSteps.slice(-20).map((s) => {
|
||
const action = escHtml(String(s.action || 'tool'));
|
||
const status = escHtml(String(s.status || 'pending'));
|
||
const stepNum = Number(s.stepNum || 0);
|
||
const meta = s.meta ? `<div class="task-meta">${escHtml(String(s.meta).slice(0, 140))}</div>` : '';
|
||
return `
|
||
<div class="task-row">
|
||
<div class="task-top">
|
||
<div class="task-title">${stepNum ? `Step ${stepNum}: ` : ''}${action}</div>
|
||
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
|
||
</div>
|
||
${meta}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function upsertLiveStep(action, stepNum, status, meta = '') {
|
||
const key = `${String(action || '')}:${Number(stepNum || 0)}`;
|
||
const idx = liveTaskSteps.findIndex(s => s.key === key);
|
||
const row = { key, action: String(action || ''), stepNum: Number(stepNum || 0), status: String(status || 'pending'), meta: String(meta || '') };
|
||
if (idx >= 0) liveTaskSteps[idx] = { ...liveTaskSteps[idx], ...row };
|
||
else liveTaskSteps.push(row);
|
||
renderLiveStepsPanel();
|
||
}
|
||
|
||
async function openSettings(tab) {
|
||
document.getElementById('settings-modal').style.display = 'flex';
|
||
setSettingsTab(tab || settingsTab || 'system');
|
||
try {
|
||
const status = await api('/api/status');
|
||
document.getElementById('settings-runtime-model').textContent = status.currentModel || '-';
|
||
loadOrchestrationConfig();
|
||
document.getElementById('settings-runtime-gateway').textContent = status.gateway || '-';
|
||
document.getElementById('settings-runtime-ollama').textContent = status.ollama ? 'Online' : 'Offline';
|
||
} catch {}
|
||
try {
|
||
const paths = await api('/api/settings/paths');
|
||
document.getElementById('settings-workspace-path').value = paths.workspace_path || '';
|
||
document.getElementById('settings-allowed-paths').value = (paths.allowed_paths || []).join('\n');
|
||
document.getElementById('settings-blocked-paths').value = (paths.blocked_paths || []).join('\n');
|
||
} catch {}
|
||
try {
|
||
const s = await api('/api/settings/search');
|
||
document.getElementById('settings-provider').value = s.preferred_provider || 'ddg';
|
||
document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified';
|
||
if (document.getElementById('settings-search-ollama-model'))
|
||
document.getElementById('settings-search-ollama-model').value = s.ollama_model || '';
|
||
if (document.getElementById('settings-search-searxng-url'))
|
||
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
|
||
toggleOllamaModelRow(s.preferred_provider);
|
||
toggleSearxngUrlRow(s.preferred_provider);
|
||
_searchSettingsLoaded = true;
|
||
// Keys are loaded via the Credentials tab — not here
|
||
} catch {}
|
||
try {
|
||
const p = await api('/api/settings/agent');
|
||
document.getElementById('settings-force-web-fresh').checked = p.force_web_for_fresh !== false;
|
||
document.getElementById('settings-memory-fallback').checked = p.memory_fallback_on_search_failure !== false;
|
||
document.getElementById('settings-auto-store-web-facts').checked = p.auto_store_web_facts !== false;
|
||
document.getElementById('settings-nl-tool-router').checked = p.natural_language_tool_router !== false;
|
||
document.getElementById('settings-retrieval-mode').value = p.retrieval_mode || 'standard';
|
||
} catch {}
|
||
}
|
||
|
||
// --- Users Tab ---------------------------------------------------------
|
||
|
||
async function loadUsersTab() {
|
||
const container = document.getElementById('users-list');
|
||
if (!container) return;
|
||
try {
|
||
const data = await api('/api/users');
|
||
if (!data || !data.users) { container.innerHTML = '<div style="font-size:12px;color:var(--muted)">인증이 비활성화되어 있거나 권한이 없습니다.</div>'; return; }
|
||
if (data.users.length === 0) {
|
||
container.innerHTML = '<div style="font-size:12px;color:var(--muted)">등록된 사용자가 없습니다.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = data.users.map(u => `
|
||
<div style="border-bottom:1px solid var(--line);padding:8px 0">
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
|
||
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
|
||
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
|
||
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserTelegram('${u.username}')">✈ 텔레그램</button>
|
||
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserEmail('${u.username}')">✉ 이메일</button>
|
||
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
|
||
</div>
|
||
<div id="user-telegram-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
|
||
<div id="user-email-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
|
||
</div>
|
||
`).join('');
|
||
} catch (e) {
|
||
container.innerHTML = '<div style="font-size:12px;color:var(--err)">사용자 목록을 불러올 수 없습니다.</div>';
|
||
}
|
||
}
|
||
|
||
async function addUser() {
|
||
const username = document.getElementById('new-user-username').value.trim();
|
||
const password = document.getElementById('new-user-password').value;
|
||
const role = document.getElementById('new-user-role').value;
|
||
const errEl = document.getElementById('add-user-error');
|
||
errEl.classList.remove('visible');
|
||
try {
|
||
const data = await api('/api/users', { method: 'POST', body: JSON.stringify({ username, password, role }) });
|
||
if (data.success) {
|
||
document.getElementById('new-user-username').value = '';
|
||
document.getElementById('new-user-password').value = '';
|
||
loadUsersTab();
|
||
} else {
|
||
errEl.textContent = data.error || '사용자 추가 실패';
|
||
errEl.classList.add('visible');
|
||
}
|
||
} catch (e) {
|
||
errEl.textContent = e.message || '사용자 추가 실패';
|
||
errEl.classList.add('visible');
|
||
}
|
||
}
|
||
|
||
async function removeUser(username) {
|
||
if (!confirm(`사용자 "${username}"를 삭제하시겠습니까?`)) return;
|
||
try {
|
||
await api(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||
loadUsersTab();
|
||
} catch (e) { alert(e.message || '삭제 실패'); }
|
||
}
|
||
|
||
// --- Per-User Telegram Bot ---------------------------------------------------
|
||
|
||
function toggleUserTelegram(username) {
|
||
const panel = document.getElementById(`user-telegram-panel-${username}`);
|
||
if (!panel) return;
|
||
if (panel.style.display === 'none') {
|
||
panel.style.display = 'block';
|
||
loadUserTelegramBot(username);
|
||
} else {
|
||
panel.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function loadUserTelegramBot(username) {
|
||
const panel = document.getElementById(`user-telegram-panel-${username}`);
|
||
if (!panel) return;
|
||
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중...</div>';
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`);
|
||
const statusColor = data.running ? 'var(--ok)' : 'var(--muted)';
|
||
const statusText = data.running ? `연결됨 (@${data.botUsername || '?'})` : '중지됨';
|
||
panel.innerHTML = `
|
||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">텔레그램 전용 봇 — ${username}</div>
|
||
<div style="font-size:11px;color:${statusColor};margin-bottom:8px">● ${statusText}</div>
|
||
<div style="display:flex;gap:6px;align-items:center">
|
||
<input id="tg-token-${username}" type="password" placeholder="${data.hasToken ? '●●●●●●●● (저장됨 — 변경 시 입력)' : '봇 토큰 입력 (BotFather에서 발급)'}"
|
||
style="flex:1;padding:5px 8px;border:1px solid var(--line);border-radius:6px;font-size:12px;background:var(--panel-1);color:var(--text)">
|
||
<button onclick="saveUserTelegramBot('${username}')" style="padding:5px 12px;font-size:11px;background:var(--brand);color:#fff;border:none;border-radius:6px;cursor:pointer">저장</button>
|
||
${data.hasToken ? `<button onclick="deleteUserTelegramBot('${username}')" style="padding:5px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer">삭제</button>` : ''}
|
||
</div>
|
||
<div id="tg-bot-msg-${username}" style="font-size:11px;margin-top:6px"></div>
|
||
`;
|
||
} catch (e) {
|
||
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function saveUserTelegramBot(username) {
|
||
const input = document.getElementById(`tg-token-${username}`);
|
||
const msg = document.getElementById(`tg-bot-msg-${username}`);
|
||
const token = input ? input.value.trim() : '';
|
||
if (!token) { if (msg) msg.textContent = '토큰을 입력해 주세요.'; return; }
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ botToken: token }),
|
||
});
|
||
if (data.success) {
|
||
if (msg) { msg.style.color = 'var(--ok)'; msg.textContent = '저장됨. 봇 연결 중...'; }
|
||
setTimeout(() => loadUserTelegramBot(username), 2000);
|
||
} else {
|
||
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '저장 실패'; }
|
||
}
|
||
} catch (e) {
|
||
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; }
|
||
}
|
||
}
|
||
|
||
async function deleteUserTelegramBot(username) {
|
||
if (!confirm(`${username}의 텔레그램 봇을 삭제하시겠습니까?`)) return;
|
||
const msg = document.getElementById(`tg-bot-msg-${username}`);
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ botToken: '' }),
|
||
});
|
||
if (data.success) loadUserTelegramBot(username);
|
||
else if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '삭제 실패'; }
|
||
} catch (e) {
|
||
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; }
|
||
}
|
||
}
|
||
|
||
// --- Per-User Email Accounts -------------------------------------------------
|
||
|
||
function toggleUserEmail(username) {
|
||
const panel = document.getElementById(`user-email-panel-${username}`);
|
||
if (!panel) return;
|
||
if (panel.style.display === 'none') {
|
||
panel.style.display = 'block';
|
||
loadUserEmailAccounts(username);
|
||
} else {
|
||
panel.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||
}
|
||
|
||
async function loadUserEmailAccounts(username) {
|
||
const panel = document.getElementById(`user-email-panel-${username}`);
|
||
if (!panel) return;
|
||
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중…</div>';
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
|
||
const accounts = data.accounts || [];
|
||
const def = data.default || '';
|
||
const formId = `email-form-${username}`;
|
||
const list = accounts.length === 0
|
||
? '<div style="font-size:12px;color:var(--muted);margin-bottom:8px">등록된 이메일 계정이 없습니다.</div>'
|
||
: accounts.map(a => `
|
||
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:12px">
|
||
<strong style="min-width:90px">${escapeHtml(a.id)}</strong>
|
||
${def === a.id ? '<span style="font-size:10px;background:var(--brand);color:#fff;padding:1px 6px;border-radius:6px">기본</span>' : ''}
|
||
<span style="color:var(--muted);flex:1;font-family:'IBM Plex Mono',monospace;font-size:11px">${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port}</span>
|
||
${a.password_set ? '' : '<span style="font-size:10px;color:var(--err)">비번 없음</span>'}
|
||
${def !== a.id ? `<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="setDefaultUserEmailAccount('${username}','${escapeHtml(a.id)}')">기본 설정</button>` : ''}
|
||
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="testUserEmailAccount('${username}','${escapeHtml(a.id)}',this)" ${a.password_set ? '' : 'disabled'}>테스트</button>
|
||
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer" onclick="deleteUserEmailAccount('${username}','${escapeHtml(a.id)}')">삭제</button>
|
||
</div>`).join('');
|
||
panel.innerHTML = `
|
||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">이메일 계정 (${accounts.length})</div>
|
||
${list}
|
||
<details style="margin-top:8px"><summary style="font-size:12px;cursor:pointer;color:var(--muted)">+ 계정 추가/수정</summary>
|
||
<div id="${formId}" style="margin-top:8px;padding:10px;background:#fff;border:1px solid var(--line);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px">
|
||
<label style="grid-column:1/3"><span style="color:var(--muted);font-size:11px">계정 ID (예: applecherry)</span>
|
||
<input data-f="id" type="text" placeholder="applecherry" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace" /></label>
|
||
<label><span style="color:var(--muted);font-size:11px">IMAP 호스트</span>
|
||
<input data-f="imap_host" type="text" placeholder="imap.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
|
||
<label><span style="color:var(--muted);font-size:11px">IMAP 포트</span>
|
||
<input data-f="imap_port" type="number" value="993" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
|
||
<label><span style="color:var(--muted);font-size:11px">SMTP 호스트</span>
|
||
<input data-f="smtp_host" type="text" placeholder="smtp.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
|
||
<label><span style="color:var(--muted);font-size:11px">SMTP 포트</span>
|
||
<input data-f="smtp_port" type="number" value="587" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
|
||
<label><span style="color:var(--muted);font-size:11px">사용자명 (이메일)</span>
|
||
<input data-f="username" type="text" placeholder="user@example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
|
||
<label><span style="color:var(--muted);font-size:11px">비밀번호 (vault에 암호화 저장)</span>
|
||
<input data-f="password" type="password" autocomplete="new-password" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
|
||
<input data-f="imap_tls" type="checkbox" checked /> IMAP TLS</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
|
||
<input data-f="smtp_secure" type="checkbox" /> SMTP secure (465)</label>
|
||
<button class="btn btn-primary" style="grid-column:1/3;padding:7px;margin-top:6px;font-size:12px" onclick="submitUserEmailAccount('${username}')">저장</button>
|
||
<div id="${formId}-msg" style="grid-column:1/3;font-size:11px;color:var(--err)"></div>
|
||
</div>
|
||
</details>`;
|
||
} catch (e) {
|
||
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">불러오기 실패: ${escapeHtml(e.message || e)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function submitUserEmailAccount(username) {
|
||
const form = document.getElementById(`email-form-${username}`);
|
||
const msg = document.getElementById(`email-form-${username}-msg`);
|
||
if (!form) return;
|
||
const get = (k) => form.querySelector(`[data-f="${k}"]`);
|
||
const id = (get('id').value || '').trim();
|
||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { msg.textContent = '계정 ID는 1-32자 영숫자/대시/언더스코어'; return; }
|
||
const body = {
|
||
imap_host: get('imap_host').value.trim(),
|
||
imap_port: Number(get('imap_port').value) || 993,
|
||
imap_tls: get('imap_tls').checked,
|
||
smtp_host: get('smtp_host').value.trim(),
|
||
smtp_port: Number(get('smtp_port').value) || 587,
|
||
smtp_secure: get('smtp_secure').checked,
|
||
username: get('username').value.trim(),
|
||
password: get('password').value,
|
||
};
|
||
if (!body.imap_host || !body.smtp_host || !body.username) { msg.textContent = 'IMAP/SMTP 호스트와 사용자명은 필수'; return; }
|
||
msg.textContent = '저장 중…'; msg.style.color = 'var(--muted)';
|
||
try {
|
||
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
|
||
method: 'PUT', body: JSON.stringify(body),
|
||
});
|
||
loadUserEmailAccounts(username);
|
||
} catch (e) {
|
||
msg.textContent = e.message || '저장 실패'; msg.style.color = 'var(--err)';
|
||
}
|
||
}
|
||
|
||
async function deleteUserEmailAccount(username, accountId) {
|
||
if (!confirm(`계정 "${accountId}" 삭제하시겠습니까? (vault의 비밀번호도 함께 삭제됩니다)`)) return;
|
||
try {
|
||
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
|
||
loadUserEmailAccounts(username);
|
||
} catch (e) { alert(e.message || '삭제 실패'); }
|
||
}
|
||
|
||
async function setDefaultUserEmailAccount(username, accountId) {
|
||
try {
|
||
await api(`/api/users/${encodeURIComponent(username)}/email/default`, {
|
||
method: 'PUT', body: JSON.stringify({ accountId }),
|
||
});
|
||
loadUserEmailAccounts(username);
|
||
} catch (e) { alert(e.message || '기본 설정 실패'); }
|
||
}
|
||
|
||
async function testUserEmailAccount(username, accountId, btn) {
|
||
const orig = btn.textContent;
|
||
btn.disabled = true; btn.textContent = '연결 중…';
|
||
try {
|
||
const r = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST' });
|
||
if (r.success) { btn.textContent = '✓ 성공'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); }
|
||
else { alert(`테스트 실패: ${r.error || 'unknown'}`); btn.textContent = orig; btn.disabled = false; }
|
||
} catch (e) {
|
||
alert(e.message || '테스트 실패'); btn.textContent = orig; btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// --- Context Viewer Tab -------------------------------------------------------
|
||
|
||
let cvAutoRefreshTimer = null;
|
||
|
||
async function loadContextViewerTab() {
|
||
const listEl = document.getElementById('cv-sessions-list');
|
||
const warnEl = document.getElementById('cv-warn-banner');
|
||
if (!listEl) return;
|
||
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>';
|
||
if (warnEl) warnEl.style.display = 'none';
|
||
|
||
try {
|
||
const data = await api('/api/admin/context-viewer');
|
||
if (!data || !data.sessions) {
|
||
listEl.innerHTML = '<div style="font-size:12px;color:var(--err)">권한이 없거나 데이터를 불러올 수 없습니다.</div>';
|
||
return;
|
||
}
|
||
const sessions = data.sessions;
|
||
if (sessions.length === 0) {
|
||
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">세션이 없습니다.</div>';
|
||
return;
|
||
}
|
||
|
||
// Token warning banner
|
||
const warnSessions = sessions.filter(s => s.usagePct >= 70);
|
||
if (warnEl && warnSessions.length > 0) {
|
||
warnEl.style.display = 'block';
|
||
warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => {
|
||
const color = s.usagePct >= 90 ? '#dc2626' : '#d97706';
|
||
return `<strong style="color:${color}">${s.username}</strong> (${s.id.slice(0,8)}…) ${s.usagePct}%`;
|
||
}).join(' | ');
|
||
}
|
||
|
||
listEl.innerHTML = sessions.map(s => {
|
||
const pct = s.usagePct;
|
||
const barColor = pct >= 90 ? 'var(--err)' : pct >= 70 ? 'var(--warn)' : 'var(--ok)';
|
||
const lastActive = s.lastActiveAt ? new Date(s.lastActiveAt).toLocaleString() : '-';
|
||
const onlineDot = s.online
|
||
? '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px"></span>'
|
||
: '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--line-strong);margin-right:4px"></span>';
|
||
return `
|
||
<div id="cv-card-${escHtml(s.id)}" style="border:1px solid var(--line);border-radius:12px;padding:12px;background:var(--panel)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
${onlineDot}
|
||
<strong style="font-size:13px">${escHtml(s.username)}</strong>
|
||
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted)">${s.id.slice(0,12)}…</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span style="font-size:11px;color:var(--muted)">${lastActive}</span>
|
||
${pct >= 70 ? `<span style="font-size:11px;font-weight:700;color:${barColor};padding:2px 7px;border-radius:6px;background:${pct>=90?'#fee2e2':'#fff7ed'}">${pct}%</span>` : ''}
|
||
<button onclick="cvDeleteSession('${escHtml(s.username)}','${escHtml(s.id)}')" title="세션 삭제" style="background:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px;color:var(--muted);cursor:pointer" onmouseover="this.style.borderColor='var(--err)';this.style.color='var(--err)'" onmouseout="this.style.borderColor='var(--line)';this.style.color='var(--muted)'">✕</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
|
||
<div style="flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden">
|
||
<div style="height:100%;width:${Math.min(pct,100)}%;background:${barColor};border-radius:3px;transition:width .3s"></div>
|
||
</div>
|
||
<span style="font-size:11px;color:var(--muted);white-space:nowrap">${s.contextTokenEstimate.toLocaleString()} / ${s.numCtx.toLocaleString()} tok</span>
|
||
</div>
|
||
<div style="display:flex;gap:8px;font-size:11px;color:var(--muted);margin-bottom:6px">
|
||
<span>메시지 ${s.historyCount}개</span>
|
||
</div>
|
||
${s.lastUserMessage ? `<div style="font-size:11px;padding:6px 8px;background:var(--panel-2);border-radius:8px;margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%"><span style="color:var(--brand);font-weight:600">사용자: </span>${escHtml(s.lastUserMessage)}</div>` : ''}
|
||
${s.lastAssistantMessage ? `<div style="font-size:11px;padding:6px 8px;background:var(--panel-2);border-radius:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%"><span style="color:var(--ok);font-weight:600">AI: </span>${escHtml(s.lastAssistantMessage)}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
listEl.innerHTML = `<div style="font-size:12px;color:var(--err)">오류: ${e.message || '알 수 없는 오류'}</div>`;
|
||
}
|
||
}
|
||
|
||
async function cvDeleteSession(username, sessionId) {
|
||
try {
|
||
const res = await fetch(`/api/admin/sessions/${encodeURIComponent(username)}/${encodeURIComponent(sessionId)}`, {
|
||
method: 'DELETE',
|
||
headers: authHeaders(),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
||
const card = document.getElementById('cv-card-' + sessionId);
|
||
if (card) card.remove();
|
||
} catch (e) {
|
||
alert('삭제 실패: ' + (e.message || e));
|
||
}
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s || '').replace(/&/g,'&').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 = '<div style="color:var(--muted)">No agents found.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = agentsConfigList.map((a) => {
|
||
const selected = a.id === selectedAgentId;
|
||
const lastRun = a.lastRun?.finishedAt ? new Date(a.lastRun.finishedAt).toLocaleString() : 'never';
|
||
const heartbeat = a.lastHeartbeatAt ? new Date(a.lastHeartbeatAt).toLocaleString() : 'never';
|
||
const badge = a.default ? '<span style="font-size:10px;padding:2px 6px;border-radius:999px;background:#eaf2ff;color:#0d4faf;border:1px solid #bdd3f6">default</span>' : '';
|
||
return `
|
||
<button onclick="selectAgent('${escHtml(a.id)}')" style="text-align:left;border:1px solid ${selected ? '#bdd3f6' : 'var(--line)'};border-radius:10px;padding:8px;background:${selected ? '#f5f9ff' : '#fff'};cursor:pointer">
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<div style="font-size:15px">${escHtml(a.emoji || '🤖')}</div>
|
||
<div style="font-weight:700;color:var(--text)">${escHtml(a.name || a.id)}</div>
|
||
${badge}
|
||
</div>
|
||
<div style="margin-top:4px;font-family:'IBM Plex Mono',monospace;color:var(--muted)">${escHtml(a.id)}</div>
|
||
<div style="margin-top:4px;font-size:11px;color:var(--muted)">last run: ${escHtml(lastRun)}</div>
|
||
<div style="font-size:11px;color:var(--muted)">last heartbeat: ${escHtml(heartbeat)}</div>
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function findSelectedAgent() {
|
||
return agentsConfigList.find(a => a.id === selectedAgentId) || null;
|
||
}
|
||
|
||
function agentFormNew() {
|
||
selectedAgentId = '';
|
||
setAgentForm({
|
||
id: '',
|
||
name: '',
|
||
emoji: '',
|
||
description: '',
|
||
workspace: '',
|
||
model: '',
|
||
cronSchedule: '',
|
||
maxSteps: '',
|
||
default: false,
|
||
minimalPrompt: true,
|
||
canSpawn: false,
|
||
spawnAllowlist: [],
|
||
tools: {},
|
||
});
|
||
if (agentMdEditor) agentMdEditor.setValue('');
|
||
const mdPath = document.getElementById('agent-md-path');
|
||
if (mdPath) mdPath.textContent = '에이전트를 선택하거나 저장하면 AGENTS.md를 불러옵니다';
|
||
const resultEl = document.getElementById('agent-spawn-result');
|
||
if (resultEl) resultEl.textContent = '';
|
||
renderAgentsList();
|
||
}
|
||
|
||
async function selectAgent(id) {
|
||
selectedAgentId = id;
|
||
const selected = findSelectedAgent();
|
||
setAgentForm(selected);
|
||
renderAgentsList();
|
||
await loadSelectedAgentMd();
|
||
await loadAgentRunHistory();
|
||
}
|
||
|
||
async function loadAgentsTab() {
|
||
ensureAgentMdEditor();
|
||
try {
|
||
const data = await api('/api/agents');
|
||
agentsConfigList = Array.isArray(data?.agents) ? data.agents : [];
|
||
if (!selectedAgentId || !agentsConfigList.some(a => a.id === selectedAgentId)) {
|
||
selectedAgentId = data?.defaultAgentId || agentsConfigList[0]?.id || '';
|
||
}
|
||
renderAgentsList();
|
||
const selected = findSelectedAgent();
|
||
setAgentForm(selected);
|
||
if (selected) {
|
||
await loadSelectedAgentMd();
|
||
await loadAgentRunHistory();
|
||
} else {
|
||
agentFormNew();
|
||
}
|
||
} catch (err) {
|
||
addProcessEntry('error', `Failed to load agents: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function saveAgentFromForm() {
|
||
const agent = getAgentFromForm();
|
||
if (!agent.id) {
|
||
alert('Agent ID is required');
|
||
return;
|
||
}
|
||
if (!agent.name) {
|
||
alert('Agent name is required');
|
||
return;
|
||
}
|
||
try {
|
||
const exists = agentsConfigList.some(a => a.id === agent.id);
|
||
const endpoint = exists ? `/api/agents/${encodeURIComponent(agent.id)}` : '/api/agents';
|
||
const method = exists ? 'PUT' : 'POST';
|
||
const data = await api(endpoint, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ agent }),
|
||
});
|
||
if (!data?.success) throw new Error(data?.error || 'save failed');
|
||
selectedAgentId = data?.agent?.id || agent.id;
|
||
addProcessEntry('info', `Agent "${selectedAgentId}" saved.`);
|
||
await loadAgentsTab();
|
||
} catch (err) {
|
||
alert(`Failed to save agent: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedAgent() {
|
||
if (!selectedAgentId) return;
|
||
if (!confirm(`Delete agent "${selectedAgentId}"?`)) return;
|
||
try {
|
||
await api(`/api/agents/${encodeURIComponent(selectedAgentId)}`, { method: 'DELETE' });
|
||
addProcessEntry('info', `Agent "${selectedAgentId}" deleted.`);
|
||
selectedAgentId = '';
|
||
await loadAgentsTab();
|
||
} catch (err) {
|
||
alert(`Failed to delete agent: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function loadSelectedAgentMd() {
|
||
ensureAgentMdEditor();
|
||
if (!selectedAgentId || !agentMdEditor) return;
|
||
try {
|
||
const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`);
|
||
agentMdEditor.setValue(data?.content || '');
|
||
const pathEl = document.getElementById('agent-md-path');
|
||
if (pathEl) pathEl.textContent = data?.path || '';
|
||
} catch (err) {
|
||
addProcessEntry('error', `Failed to load AGENTS.md: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function saveSelectedAgentMd() {
|
||
if (!selectedAgentId || !agentMdEditor) return;
|
||
try {
|
||
const content = agentMdEditor.getValue();
|
||
await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
addProcessEntry('final', `Saved AGENTS.md for "${selectedAgentId}".`);
|
||
} catch (err) {
|
||
alert(`Failed to save AGENTS.md: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function runSelectedAgentOnce() {
|
||
if (!selectedAgentId) return;
|
||
const task = (document.getElementById('agent-spawn-task').value || '').trim();
|
||
if (!task) {
|
||
alert('Provide a task first.');
|
||
return;
|
||
}
|
||
const out = document.getElementById('agent-spawn-result');
|
||
if (out) out.textContent = '실행 중...';
|
||
try {
|
||
const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/spawn`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ task }),
|
||
});
|
||
const result = data?.result || {};
|
||
if (out) {
|
||
const head = result.success ? `Success (${result.durationMs || 0}ms)` : `Failed: ${result.error || 'unknown error'}`;
|
||
out.textContent = `${head}\n\n${String(result.result || '').slice(0, 2500)}`;
|
||
}
|
||
await loadAgentsTab();
|
||
} catch (err) {
|
||
if (out) out.textContent = `Run failed: ${err.message}`;
|
||
}
|
||
}
|
||
|
||
async function loadAgentRunHistory() {
|
||
const el = document.getElementById('agent-run-history');
|
||
if (!el) return;
|
||
if (!selectedAgentId) { el.innerHTML = ''; return; }
|
||
try {
|
||
const data = await api(`/api/agents/history?agentId=${encodeURIComponent(selectedAgentId)}&limit=12`);
|
||
const rows = Array.isArray(data?.history) ? data.history : [];
|
||
if (!rows.length) {
|
||
el.innerHTML = '<div style="color:var(--muted)">No runs yet.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = rows.map((r) => {
|
||
const color = r.success ? '#1a6e35' : '#9c1a1a';
|
||
const when = new Date(r.finishedAt || r.startedAt || Date.now()).toLocaleString();
|
||
const label = `${r.trigger || 'manual'} • ${r.durationMs || 0}ms`;
|
||
const preview = String(r.resultPreview || r.error || '').slice(0, 140);
|
||
return `<div style="border:1px solid var(--line);border-radius:8px;padding:6px 8px">
|
||
<div style="display:flex;justify-content:space-between;gap:8px">
|
||
<span style="font-weight:700;color:${color}">${r.success ? 'success' : 'failed'}</span>
|
||
<span style="font-size:11px;color:var(--muted)">${escHtml(when)}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--muted)">${escHtml(label)}</div>
|
||
<div style="font-size:11px;color:var(--text);margin-top:2px">${escHtml(preview)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (err) {
|
||
el.innerHTML = `<div style="color:#9c1a1a">Failed to load history: ${escHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// --- Channels Settings Functions ---------------------------------------
|
||
let channelsStatusLoaded = false;
|
||
|
||
function setChannelStatus(idPrefix, state, message) {
|
||
const dot = document.getElementById(`${idPrefix}-status-dot`);
|
||
const text = document.getElementById(`${idPrefix}-status-text`);
|
||
if (!dot || !text) return;
|
||
if (state === 'ok') {
|
||
dot.style.background = 'var(--ok)';
|
||
text.style.color = 'var(--text)';
|
||
} else if (state === 'warn') {
|
||
dot.style.background = '#f0ad4e';
|
||
text.style.color = '#b87b00';
|
||
} else {
|
||
dot.style.background = '#ccc';
|
||
text.style.color = 'var(--muted)';
|
||
}
|
||
text.textContent = message || '미설정';
|
||
}
|
||
|
||
function readChannelPayload(channel) {
|
||
if (channel === 'kakao') {
|
||
let userMap = {};
|
||
try { userMap = JSON.parse(document.getElementById('settings-kk-usermap')?.value || '{}'); } catch {}
|
||
return {
|
||
appKey: (document.getElementById('settings-kk-appkey')?.value || '').trim(),
|
||
channelEnabled: !!document.getElementById('settings-kk-channel-enabled')?.checked,
|
||
userMap,
|
||
};
|
||
}
|
||
if (channel === 'telegram') {
|
||
const userIdStr = (document.getElementById('settings-tg-userid')?.value || '').trim();
|
||
const allowedUserIds = userIdStr ? userIdStr.split(/[,\s]+/).map(Number).filter(n => !isNaN(n) && n > 0) : [];
|
||
return {
|
||
enabled: !!document.getElementById('settings-tg-enabled')?.checked,
|
||
botToken: (document.getElementById('settings-tg-token')?.value || '').trim(),
|
||
allowedUserIds,
|
||
streamMode: 'full',
|
||
};
|
||
}
|
||
if (channel === 'discord') {
|
||
return {
|
||
enabled: !!document.getElementById('settings-dc-enabled')?.checked,
|
||
botToken: (document.getElementById('settings-dc-token')?.value || '').trim(),
|
||
applicationId: (document.getElementById('settings-dc-appid')?.value || '').trim(),
|
||
guildId: (document.getElementById('settings-dc-guildid')?.value || '').trim(),
|
||
channelId: (document.getElementById('settings-dc-channelid')?.value || '').trim(),
|
||
webhookUrl: (document.getElementById('settings-dc-webhook')?.value || '').trim(),
|
||
};
|
||
}
|
||
return {
|
||
enabled: !!document.getElementById('settings-wa-enabled')?.checked,
|
||
accessToken: (document.getElementById('settings-wa-token')?.value || '').trim(),
|
||
phoneNumberId: (document.getElementById('settings-wa-phoneid')?.value || '').trim(),
|
||
businessAccountId: (document.getElementById('settings-wa-baid')?.value || '').trim(),
|
||
verifyToken: (document.getElementById('settings-wa-verify')?.value || '').trim(),
|
||
webhookSecret: (document.getElementById('settings-wa-secret')?.value || '').trim(),
|
||
testRecipient: (document.getElementById('settings-wa-recipient')?.value || '').trim(),
|
||
};
|
||
}
|
||
|
||
function setButtonBusy(id, busy, busyLabel, normalLabel) {
|
||
const btn = document.getElementById(id);
|
||
if (!btn) return;
|
||
btn.disabled = !!busy;
|
||
btn.textContent = busy ? busyLabel : normalLabel;
|
||
}
|
||
|
||
function getSelectedChannelType() {
|
||
return String(document.getElementById('settings-channel-select')?.value || 'telegram');
|
||
}
|
||
|
||
function onChannelTypeChange() {
|
||
const ch = getSelectedChannelType();
|
||
const formMap = {
|
||
telegram: 'channel-form-telegram',
|
||
kakao: 'channel-form-kakao',
|
||
discord: 'channel-form-discord',
|
||
whatsapp: 'channel-form-whatsapp',
|
||
email: 'channel-form-email',
|
||
};
|
||
const guideMap = {
|
||
telegram: 'channel-guide-telegram',
|
||
kakao: 'channel-guide-kakao',
|
||
discord: 'channel-guide-discord',
|
||
whatsapp: 'channel-guide-whatsapp',
|
||
email: 'channel-guide-email',
|
||
};
|
||
const statusMap = {
|
||
telegram: 'tg-status-bar',
|
||
kakao: 'kk-status-bar',
|
||
discord: 'dc-status-bar',
|
||
whatsapp: 'wa-status-bar',
|
||
email: 'em-status-bar',
|
||
};
|
||
['telegram', 'kakao', 'discord', 'whatsapp', 'email'].forEach((k) => {
|
||
const form = document.getElementById(formMap[k]);
|
||
const guide = document.getElementById(guideMap[k]);
|
||
const status = document.getElementById(statusMap[k]);
|
||
if (form) form.style.display = (k === ch) ? 'block' : 'none';
|
||
if (guide) guide.style.display = (k === ch) ? 'block' : 'none';
|
||
if (status) status.style.display = (k === ch) ? 'flex' : 'none';
|
||
});
|
||
// Email: hide test/send-test buttons (email has per-account test)
|
||
// Kakao: hide "테스트" (연결 확인 없음), 테스트 전송만 노출
|
||
const testBtn = document.getElementById('channel-test-btn');
|
||
const sendTestBtn = document.getElementById('channel-send-test-btn');
|
||
if (testBtn) testBtn.style.display = (ch === 'email' || ch === 'kakao') ? 'none' : '';
|
||
if (sendTestBtn) sendTestBtn.style.display = (ch === 'email') ? 'none' : '';
|
||
if (ch === 'email') loadEmailAccounts();
|
||
if (ch === 'kakao') loadKakaoStatus();
|
||
}
|
||
|
||
async function saveSelectedChannelSettings() {
|
||
await saveChannelSettings(getSelectedChannelType());
|
||
}
|
||
|
||
async function testSelectedChannel() {
|
||
await testChannel(getSelectedChannelType());
|
||
}
|
||
|
||
async function sendSelectedChannelTest() {
|
||
await sendChannelTest(getSelectedChannelType());
|
||
}
|
||
|
||
async function loadChannelsStatus() {
|
||
try {
|
||
const data = await api('/api/channels/status');
|
||
if (!data?.success) return;
|
||
const tg = data.telegram || {};
|
||
const dc = data.discord || {};
|
||
const wa = data.whatsapp || {};
|
||
|
||
if (tg.connected && tg.polling) setChannelStatus('tg', 'ok', `Connected as @${tg.username || 'bot'}`);
|
||
else if (tg.hasToken) setChannelStatus('tg', 'warn', 'Token saved, not connected');
|
||
else setChannelStatus('tg', 'off', '미설정');
|
||
|
||
if (dc.hasToken || dc.hasWebhook) setChannelStatus('dc', 'warn', 'Credentials saved');
|
||
else setChannelStatus('dc', 'off', '미설정');
|
||
|
||
if (wa.hasAccessToken && wa.phoneNumberId) setChannelStatus('wa', 'warn', 'Credentials saved');
|
||
else setChannelStatus('wa', 'off', '미설정');
|
||
|
||
if (!channelsStatusLoaded) {
|
||
channelsStatusLoaded = true;
|
||
if (tg.hasToken) document.getElementById('settings-tg-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)';
|
||
if (dc.hasToken) document.getElementById('settings-dc-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)';
|
||
if (wa.hasAccessToken) document.getElementById('settings-wa-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)';
|
||
|
||
document.getElementById('settings-tg-enabled').checked = !!tg.enabled;
|
||
document.getElementById('settings-tg-userid').value = (tg.allowedUserIds || []).join(', ');
|
||
|
||
document.getElementById('settings-dc-enabled').checked = !!dc.enabled;
|
||
document.getElementById('settings-dc-appid').value = dc.applicationId || '';
|
||
document.getElementById('settings-dc-guildid').value = dc.guildId || '';
|
||
document.getElementById('settings-dc-channelid').value = dc.channelId || '';
|
||
|
||
document.getElementById('settings-wa-enabled').checked = !!wa.enabled;
|
||
document.getElementById('settings-wa-phoneid').value = wa.phoneNumberId || '';
|
||
document.getElementById('settings-wa-baid').value = wa.businessAccountId || '';
|
||
document.getElementById('settings-wa-recipient').value = wa.testRecipient || '';
|
||
}
|
||
onChannelTypeChange();
|
||
} catch (err) {
|
||
console.error('[Channels] Status load failed:', err);
|
||
}
|
||
// Load heartbeat notify toggle
|
||
try {
|
||
const hbData = await api('/api/heartbeat/config');
|
||
if (hbData?.success) {
|
||
document.getElementById('settings-hb-notify').checked = hbData.config.notifyEnabled !== false;
|
||
updateHbNotifyLabel();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function loadKakaoStatus() {
|
||
try {
|
||
const data = await api('/api/kakao/status');
|
||
const n = data?.notify || {};
|
||
const c = data?.channel || {};
|
||
if (n.hasAppKey && n.hasRefreshToken) setChannelStatus('kk', 'ok', '알림 연동됨' + (c.enabled ? ' + 챗봇 활성' : ''));
|
||
else if (n.hasAppKey) setChannelStatus('kk', 'warn', 'API 키 있음 — OAuth 로그인 필요');
|
||
else setChannelStatus('kk', 'off', '미설정');
|
||
if (n.hasAppKey) document.getElementById('settings-kk-appkey').placeholder = '•••••••• (저장됨)';
|
||
document.getElementById('settings-kk-channel-enabled').checked = !!c.enabled;
|
||
} catch {}
|
||
}
|
||
|
||
async function kakaoOAuthStart() {
|
||
const appKey = (document.getElementById('settings-kk-appkey')?.value || '').trim();
|
||
if (appKey) {
|
||
// 먼저 appKey 저장
|
||
await saveChannelSettings('kakao');
|
||
}
|
||
const status = document.getElementById('kk-oauth-status');
|
||
if (status) status.textContent = '새 창에서 로그인 중...';
|
||
const w = window.open('/api/kakao/auth/start', '_blank', 'width=600,height=700');
|
||
if (!w) { alert('팝업이 차단됐습니다. 팝업을 허용해 주세요.'); return; }
|
||
const timer = setInterval(async () => {
|
||
if (w.closed) {
|
||
clearInterval(timer);
|
||
await loadKakaoStatus();
|
||
if (status) status.textContent = '';
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
async function saveChannelSettings(channel) {
|
||
const payload = readChannelPayload(channel);
|
||
try {
|
||
const data = await api('/api/channels/config', {
|
||
method: 'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ channels: { [channel]: payload } }),
|
||
});
|
||
if (data?.success) {
|
||
addProcessEntry('final', `${channel} settings saved.`);
|
||
channelsStatusLoaded = false;
|
||
await loadChannelsStatus();
|
||
} else {
|
||
alert('Save failed: ' + (data?.error || 'unknown error'));
|
||
}
|
||
} catch (err) {
|
||
alert('Save failed: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function testChannel(channel) {
|
||
const btnId = document.getElementById('channel-test-btn')
|
||
? 'channel-test-btn'
|
||
: channel === 'telegram' ? 'tg-test-btn' : channel === 'discord' ? 'dc-test-btn' : 'wa-test-btn';
|
||
setButtonBusy(btnId, true, 'Testing...', 'Test');
|
||
try {
|
||
const payload = readChannelPayload(channel);
|
||
const data = await api(`/api/channels/test/${channel}`, {
|
||
method: 'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (data?.success) {
|
||
if (channel === 'telegram') setChannelStatus('tg', 'ok', `Valid bot @${data?.bot?.username || 'telegram'}`);
|
||
if (channel === 'discord') setChannelStatus('dc', 'ok', `Valid bot @${data?.bot?.username || 'discord'}`);
|
||
if (channel === 'whatsapp') setChannelStatus('wa', 'ok', `Valid phone ${data?.account?.display_phone_number || data?.account?.id || ''}`.trim());
|
||
addProcessEntry('final', `${channel} connection test passed.`);
|
||
} else {
|
||
alert(`${channel} test failed: ${data?.error || 'unknown error'}`);
|
||
}
|
||
} catch (err) {
|
||
alert(`${channel} test failed: ${err.message}`);
|
||
}
|
||
setButtonBusy(btnId, false, 'Testing...', 'Test');
|
||
}
|
||
|
||
async function sendChannelTest(channel) {
|
||
const btnId = document.getElementById('channel-send-test-btn')
|
||
? 'channel-send-test-btn'
|
||
: channel === 'telegram' ? 'tg-send-test-btn' : channel === 'discord' ? 'dc-send-test-btn' : 'wa-send-test-btn';
|
||
setButtonBusy(btnId, true, 'Sending...', 'Send Test');
|
||
try {
|
||
const payload = readChannelPayload(channel);
|
||
const data = await api(`/api/channels/send-test/${channel}`, {
|
||
method: 'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (data?.success) {
|
||
addProcessEntry('final', `Test message sent via ${channel}.`);
|
||
} else {
|
||
alert(`${channel} send-test failed: ${data?.error || 'unknown error'}`);
|
||
}
|
||
} catch (err) {
|
||
alert(`${channel} send-test failed: ${err.message}`);
|
||
}
|
||
setButtonBusy(btnId, false, 'Sending...', 'Send Test');
|
||
}
|
||
|
||
// ─── Email Account Management (Channel Settings) ─────────────────────────
|
||
let emailAccountsCache = [];
|
||
|
||
function getCurrentUsername() {
|
||
if (typeof currentUser === 'object' && currentUser?.username) return currentUser.username;
|
||
try { return localStorage.getItem(LAST_USER_KEY) || ''; } catch { return ''; }
|
||
}
|
||
|
||
async function loadEmailAccounts() {
|
||
const username = getCurrentUsername();
|
||
if (!username) {
|
||
document.getElementById('email-accounts-list').innerHTML = '<div style="font-size:12px;color:var(--muted)">사용자 정보를 불러올 수 없습니다.</div>';
|
||
return;
|
||
}
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
|
||
const accounts = data?.accounts || [];
|
||
emailAccountsCache = accounts;
|
||
const count = accounts.length;
|
||
if (count > 0) setChannelStatus('em', 'ok', `${count}개 계정 연결됨`);
|
||
else setChannelStatus('em', 'off', '미설정');
|
||
renderEmailAccountsList(accounts);
|
||
} catch (err) {
|
||
document.getElementById('email-accounts-list').innerHTML = '<div style="font-size:12px;color:var(--muted)">이메일 계정을 불러올 수 없습니다.</div>';
|
||
}
|
||
}
|
||
|
||
function renderEmailAccountsList(accounts) {
|
||
const container = document.getElementById('email-accounts-list');
|
||
if (!accounts.length) {
|
||
container.innerHTML = '<div style="font-size:12px;color:var(--muted);margin-bottom:6px">등록된 이메일 계정이 없습니다.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = accounts.map(a => {
|
||
const esc = s => String(s||'').replace(/</g,'<');
|
||
return `<div style="border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:6px;background:var(--panel-2)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||
<div>
|
||
<span style="font-weight:600;font-size:13px">${esc(a.id)}</span>
|
||
<span style="font-size:11px;color:var(--muted);margin-left:6px">${esc(a.username)}</span>
|
||
</div>
|
||
<div style="display:flex;gap:4px">
|
||
<button class="btn btn-sm" onclick="testEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid var(--line);color:var(--text);padding:3px 8px;font-size:11px">테스트</button>
|
||
<button class="btn btn-sm" onclick="editEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid var(--line);color:var(--text);padding:3px 8px;font-size:11px">수정</button>
|
||
<button class="btn btn-sm" onclick="deleteEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid #e55;color:#e55;padding:3px 8px;font-size:11px">삭제</button>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:2px">IMAP: ${esc(a.imap_host)}:${a.imap_port} | SMTP: ${esc(a.smtp_host)}:${a.smtp_port}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function saveEmailAccount() {
|
||
const username = getCurrentUsername();
|
||
if (!username) { alert('사용자 정보를 확인할 수 없습니다.'); return; }
|
||
const id = document.getElementById('em-id').value.trim();
|
||
if (!id) { alert('계정 ID를 입력하세요.'); return; }
|
||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { alert('계정 ID는 1-32자의 영문, 숫자, 대시, 언더스코어만 가능합니다.'); return; }
|
||
const username_field = document.getElementById('em-username').value.trim();
|
||
if (!username_field) { alert('이메일 주소를 입력하세요.'); return; }
|
||
const imap_host = document.getElementById('em-imap-host').value.trim();
|
||
if (!imap_host) { alert('IMAP 서버를 입력하세요.'); return; }
|
||
const smtp_host = document.getElementById('em-smtp-host').value.trim();
|
||
if (!smtp_host) { alert('SMTP 서버를 입력하세요.'); return; }
|
||
const payload = {
|
||
imap_host,
|
||
imap_port: Number(document.getElementById('em-imap-port').value) || 993,
|
||
imap_tls: !!document.getElementById('em-imap-tls').checked,
|
||
imap_reject_unauthorized: true,
|
||
smtp_host,
|
||
smtp_port: Number(document.getElementById('em-smtp-port').value) || 587,
|
||
smtp_secure: !!document.getElementById('em-smtp-secure').checked,
|
||
smtp_reject_unauthorized: true,
|
||
username: username_field,
|
||
password: document.getElementById('em-password').value || undefined,
|
||
};
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (data?.success) {
|
||
addProcessEntry('final', `Email account "${id}" saved.`);
|
||
document.getElementById('em-password').value = '';
|
||
await loadEmailAccounts();
|
||
} else {
|
||
alert('저장 실패: ' + (data?.error || '알 수 없는 오류'));
|
||
}
|
||
} catch (err) {
|
||
alert('저장 실패: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function testEmailAccount(accountId) {
|
||
const username = getCurrentUsername();
|
||
if (!username) return;
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, {
|
||
method: 'POST',
|
||
});
|
||
if (data?.success) {
|
||
addProcessEntry('final', `Email account "${accountId}" IMAP connection OK.`);
|
||
} else {
|
||
alert(`테스트 실패: ${data?.error || '알 수 없는 오류'}`);
|
||
}
|
||
} catch (err) {
|
||
alert('테스트 실패: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function editEmailAccount(accountId) {
|
||
const acct = emailAccountsCache.find(a => a.id === accountId);
|
||
if (!acct) return;
|
||
document.getElementById('em-id').value = acct.id;
|
||
document.getElementById('em-username').value = acct.username || '';
|
||
document.getElementById('em-imap-host').value = acct.imap_host || '';
|
||
document.getElementById('em-imap-port').value = acct.imap_port || 993;
|
||
document.getElementById('em-smtp-host').value = acct.smtp_host || '';
|
||
document.getElementById('em-smtp-port').value = acct.smtp_port || 587;
|
||
document.getElementById('em-imap-tls').checked = acct.imap_tls !== false;
|
||
document.getElementById('em-smtp-secure').checked = acct.smtp_secure === true;
|
||
document.getElementById('em-password').value = '';
|
||
document.getElementById('em-password').placeholder = '•••••••• (변경 시에만 입력)';
|
||
}
|
||
|
||
async function deleteEmailAccount(accountId) {
|
||
if (!confirm(`이메일 계정 "${accountId}"를 삭제하시겠습니까?`)) return;
|
||
const username = getCurrentUsername();
|
||
if (!username) return;
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (data?.success) {
|
||
addProcessEntry('final', `Email account "${accountId}" deleted.`);
|
||
await loadEmailAccounts();
|
||
} else {
|
||
alert('삭제 실패: ' + (data?.error || '알 수 없는 오류'));
|
||
}
|
||
} catch (err) {
|
||
alert('삭제 실패: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ─── Heartbeat Notify Toggle ──────────────────────────────────────────────
|
||
function updateHbNotifyLabel() {
|
||
const on = document.getElementById('settings-hb-notify')?.checked;
|
||
const label = document.getElementById('settings-hb-notify-label');
|
||
if (label) label.textContent = on ? '알림 받기' : '알림 끄기';
|
||
}
|
||
|
||
async function saveHbNotifyConfig() {
|
||
const notifyEnabled = !!document.getElementById('settings-hb-notify')?.checked;
|
||
try {
|
||
const data = await api('/api/heartbeat/config', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ notifyEnabled }),
|
||
});
|
||
if (data?.success) {
|
||
updateHbNotifyLabel();
|
||
addProcessEntry('final', notifyEnabled ? '허트비트 알림 켜짐' : '허트비트 알림 꺼짐');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const workspace_path = document.getElementById('settings-workspace-path').value.trim();
|
||
const allowed_paths = document.getElementById('settings-allowed-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||
const blocked_paths = document.getElementById('settings-blocked-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||
// If search settings were never loaded from the server (API failed or race with async load),
|
||
// fetch current values now so we don't overwrite searxng_url / preferred_provider with HTML defaults.
|
||
if (!_searchSettingsLoaded) {
|
||
try {
|
||
const s = await api('/api/settings/search');
|
||
document.getElementById('settings-provider').value = s.preferred_provider || '';
|
||
document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified';
|
||
if (document.getElementById('settings-search-searxng-url'))
|
||
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
|
||
if (document.getElementById('settings-search-ollama-model'))
|
||
document.getElementById('settings-search-ollama-model').value = s.ollama_model || '';
|
||
_searchSettingsLoaded = true;
|
||
} catch {}
|
||
}
|
||
const payload = {
|
||
preferred_provider: document.getElementById('settings-provider').value,
|
||
search_rigor: document.getElementById('settings-search-rigor').value,
|
||
tavily_api_key: document.getElementById('cred-tavily-key')?.value.trim() || '',
|
||
google_api_key: document.getElementById('cred-google-key')?.value.trim() || '',
|
||
google_cx: document.getElementById('cred-google-cx')?.value.trim() || '',
|
||
brave_api_key: document.getElementById('cred-brave-key')?.value.trim() || '',
|
||
ollama_model: document.getElementById('settings-search-ollama-model')?.value.trim() || '',
|
||
searxng_url: document.getElementById('settings-search-searxng-url')?.value.trim() || '',
|
||
};
|
||
const policyPayload = {
|
||
force_web_for_fresh: document.getElementById('settings-force-web-fresh').checked,
|
||
memory_fallback_on_search_failure: document.getElementById('settings-memory-fallback').checked,
|
||
auto_store_web_facts: document.getElementById('settings-auto-store-web-facts').checked,
|
||
natural_language_tool_router: document.getElementById('settings-nl-tool-router').checked,
|
||
retrieval_mode: document.getElementById('settings-retrieval-mode').value,
|
||
};
|
||
// If model/provider settings were never loaded (models tab not opened), fetch and populate DOM now
|
||
// so buildProviderPayload() doesn't send HTML defaults (provider='ollama', empty model, etc.)
|
||
if (!_modelSettingsLoaded) {
|
||
try {
|
||
const data = await api('/api/settings/provider');
|
||
const llm = data?.llm || {};
|
||
const prov = llm.provider || '';
|
||
const pc = llm.providers || {};
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
|
||
if (prov) { const el = document.getElementById('settings-llm-provider'); if (el) el.value = prov; }
|
||
v('settings-ollama-endpoint', pc.ollama?.endpoint);
|
||
v('settings-llamacpp-endpoint', pc.llama_cpp?.endpoint);
|
||
v('settings-llamacpp-model', pc.llama_cpp?.model);
|
||
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
|
||
v('settings-lmstudio-model', pc.lm_studio?.model);
|
||
const savedPrimary = pc[prov]?.model || data?.models_primary || '';
|
||
if (savedPrimary) { const el = document.getElementById('settings-primary-model'); if (el) el.value = savedPrimary; }
|
||
_modelSettingsLoaded = true;
|
||
} catch {}
|
||
}
|
||
// Capture primaryModel AFTER _modelSettingsLoaded guard so the DOM value is correct
|
||
// even when the models tab was never opened in this settings session.
|
||
const primaryModel = (document.getElementById('settings-primary-model') || {}).value || '';
|
||
const modelPayload = {
|
||
ollama_endpoint: (document.getElementById('settings-ollama-endpoint') || {}).value || 'http://localhost:11434',
|
||
primary: primaryModel,
|
||
roles: {
|
||
manager: primaryModel,
|
||
executor: primaryModel,
|
||
verifier: primaryModel,
|
||
}
|
||
};
|
||
const providerPayload = buildProviderPayload();
|
||
// Keep llm.providers.ollama.model in sync with the primary model
|
||
// so getModelForRole() never falls back to the hardcoded 'qwen3:4b'.
|
||
if (providerPayload.providers && providerPayload.providers.ollama) {
|
||
providerPayload.providers.ollama.model = primaryModel || providerPayload.providers.ollama.model || '';
|
||
}
|
||
try {
|
||
await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ workspace_path, allowed_paths, blocked_paths }) });
|
||
await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) });
|
||
await api('/api/settings/agent', { method: 'POST', body: JSON.stringify(policyPayload) });
|
||
await api('/api/settings/model', { method: 'POST', body: JSON.stringify(modelPayload) });
|
||
await api('/api/settings/provider', { method: 'POST', body: JSON.stringify({ llm: providerPayload }) });
|
||
const owKey = document.getElementById('cred-ow-key')?.value?.trim();
|
||
if (owKey && owKey !== '••••••••') {
|
||
await api('/api/settings/openweather', { method: 'POST', body: JSON.stringify({ api_key: owKey }) });
|
||
}
|
||
await loadSearchSettingsSummary();
|
||
quickSearchRigor = payload.search_rigor || quickSearchRigor;
|
||
updateQuickModeUI();
|
||
addProcessEntry('final', '설정이 저장되었습니다.');
|
||
closeSettings();
|
||
} catch (err) {
|
||
addProcessEntry('error', `설정 저장 실패: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// Approve a memory suggestion shown in the process log
|
||
async function confirmMemory(index) {
|
||
const entry = processLogEntries[index];
|
||
if (!entry || entry.type !== 'memory') return;
|
||
const suggestion = entry.extra || {};
|
||
try {
|
||
addProcessEntry('info', 'Saving memory...');
|
||
const res = await api('/api/memory/confirm', { method: 'POST', body: JSON.stringify(suggestion) });
|
||
if (res && res.ok === false) {
|
||
addProcessEntry('error', `Memory save failed: ${res.error || JSON.stringify(res)}`);
|
||
return;
|
||
}
|
||
processLogEntries[index].type = 'result';
|
||
processLogEntries[index].content = `Saved memory: ${String(entry.content).slice(0,200)}`;
|
||
renderProcessLog();
|
||
addProcessEntry('final', 'Memory persisted.');
|
||
} catch (err) {
|
||
addProcessEntry('error', `Failed to save memory: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function rejectMemory(index) {
|
||
const entry = processLogEntries[index];
|
||
if (!entry || entry.type !== 'memory') return;
|
||
processLogEntries[index].type = 'warn';
|
||
processLogEntries[index].content = 'Memory suggestion rejected.';
|
||
renderProcessLog();
|
||
addProcessEntry('info', 'Memory suggestion rejected by user.');
|
||
}
|
||
|
||
// ---- Run mission ----
|
||
async function runMission() {
|
||
const input = document.getElementById('mission-input');
|
||
const btn = document.getElementById('run-btn');
|
||
const mission = input.value.trim();
|
||
if (!mission) return;
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Starting...';
|
||
|
||
try {
|
||
const res = await api('/api/jobs', { method: 'POST', body: JSON.stringify({ mission }) });
|
||
if (res.error) {
|
||
log(`Error: ${res.error}`, 'error');
|
||
} else {
|
||
log(`Mission started ? Job ${res.jobId.slice(0, 8)}...`, 'success');
|
||
input.value = '';
|
||
setTimeout(() => selectJob(res.jobId), 500);
|
||
}
|
||
} catch (err) {
|
||
log(`Failed: ${err.message}`, 'error');
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.innerHTML = '? Run';
|
||
}
|
||
|
||
// ---- Sessions list ----
|
||
function renderSessionsList() {
|
||
const el = document.getElementById('jobs-list');
|
||
if (!chatSessions || chatSessions.length === 0) {
|
||
el.innerHTML = '<div class="empty-state">No chats yet.<br>Start a new conversation.</div>';
|
||
return;
|
||
}
|
||
const sorted = [...chatSessions].sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt));
|
||
el.innerHTML = sorted.map(s => `
|
||
<div class="job-item ${s.id === activeChatSessionId ? 'active' : ''}${s.unread ? ' unread' : ''}" onclick="openSession('${s.id}')">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
||
<div class="job-item-title" style="flex:1">${s.unread ? '🔵 ' : ''}${escHtml(s.title || 'New chat')}</div>
|
||
${sessionsEditMode ? `<button class="sidebar-action" style="padding:3px 8px;font-size:10px" onclick="deleteChatSession('${s.id}', event)">삭제</button>` : ''}
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
|
||
${s.automated ? '<span class="session-auto-badge">Auto</span>' : ''}
|
||
<span class="badge badge-queued">${(s.history || []).length} msgs</span>
|
||
<span style="color:var(--muted);font-size:10px">${timeAgo(s.updatedAt || s.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ---- Select job ----
|
||
async function selectJob(jobId) {
|
||
selectedJobId = jobId;
|
||
setMode('chat');
|
||
renderJobsList(allJobs);
|
||
await refreshJobDetail(jobId);
|
||
clearInterval(pollInterval);
|
||
pollInterval = setInterval(async () => {
|
||
const job = allJobs.find(j => j.id === jobId);
|
||
if (job && ['planning', 'executing', 'verifying'].includes(job.status)) {
|
||
await refreshJobDetail(jobId);
|
||
} else {
|
||
clearInterval(pollInterval);
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
async function refreshJobDetail(jobId) {
|
||
try {
|
||
const data = await api(`/api/jobs/${jobId}`);
|
||
renderJobDetail(data);
|
||
} catch {}
|
||
}
|
||
|
||
function renderJobDetail(data) {
|
||
const { job, tasks, artifacts, state } = data;
|
||
const content = document.getElementById('main-content');
|
||
const isRunning = ['planning','executing','verifying'].includes(job.status);
|
||
|
||
content.innerHTML = `
|
||
<div class="job-detail-title">${escHtml(job.title)}</div>
|
||
<div class="job-detail-meta">
|
||
<span class="badge badge-${job.status}">${isRunning ? '<span class="spinner"></span>' : ''}${job.status}</span>
|
||
<span>ID: ${job.id.slice(0, 12)}...</span>
|
||
<span>${timeAgo(job.created_at)}</span>
|
||
</div>
|
||
|
||
${state ? `
|
||
<div class="section-title">Mission</div>
|
||
<div style="color:var(--muted);font-size:12px;line-height:1.7;background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:16px">
|
||
${escHtml(state.mission)}
|
||
</div>` : ''}
|
||
|
||
${tasks && tasks.length > 0 ? `
|
||
<div class="section-title">Tasks (${tasks.length})</div>
|
||
${tasks.map(t => `
|
||
<div class="task-card">
|
||
<div class="task-card-header">
|
||
<span class="task-card-title">${escHtml(t.title)}</span>
|
||
<span class="badge badge-${t.status}">${t.status}</span>
|
||
</div>
|
||
<div class="task-card-desc">${escHtml(t.description || '')}</div>
|
||
${t.acceptance_criteria && t.acceptance_criteria.length > 0 ? `
|
||
<div class="task-criteria">
|
||
${t.acceptance_criteria.map(c => `<div class="task-criteria-item">${escHtml(c)}</div>`).join('')}
|
||
</div>` : ''}
|
||
</div>
|
||
`).join('')}` : `
|
||
<div class="section-title">작업</div>
|
||
<div style="color:var(--muted);font-size:12px;padding:20px 0">
|
||
${isRunning ? '<span class="spinner pulsing"></span> Planning tasks...' : 'No tasks'}
|
||
</div>`}
|
||
|
||
${artifacts && artifacts.length > 0 ? `
|
||
<div class="section-title" style="margin-top:16px">Artifacts (${artifacts.length})</div>
|
||
${artifacts.map(a => `
|
||
<div class="task-card">
|
||
<div class="task-card-header">
|
||
<span class="task-card-title">${escHtml(a.path || a.type)}</span>
|
||
<span class="badge badge-completed">${a.type}</span>
|
||
</div>
|
||
${a.content ? `<div class="task-card-desc" style="margin-top:6px">${escHtml(a.content.slice(0, 200))}${a.content.length > 200 ? '...' : ''}</div>` : ''}
|
||
</div>
|
||
`).join('')}` : ''}
|
||
|
||
<div id="log-panel">${logLines.map(l => `<div class="log-line ${l.type}">${l.text}</div>`).join('')}</div>
|
||
`;
|
||
|
||
const lp = document.getElementById('log-panel');
|
||
if (lp) lp.scrollTop = lp.scrollHeight;
|
||
}
|
||
|
||
// ---- Stats ----
|
||
function updateStats(jobs) {
|
||
const totalEl = document.getElementById('r-total');
|
||
const runningEl = document.getElementById('r-running');
|
||
const modeEl = document.getElementById('r-completed');
|
||
if (totalEl) totalEl.textContent = String(chatSessions.length);
|
||
if (runningEl) runningEl.textContent = activeChatSessionId ? '1' : '0';
|
||
if (modeEl) modeEl.textContent = useAgentMode ? 'On' : 'Off';
|
||
}
|
||
|
||
// ---- Approvals ----
|
||
async function loadApprovals() {
|
||
try {
|
||
const approvals = await api('/api/approvals');
|
||
const el = document.getElementById('approvals-list');
|
||
if (!approvals || approvals.length === 0) {
|
||
el.innerHTML = '<div style="color:var(--muted);font-size:11px">대기 중인 승인 없음</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = approvals.map(a => `
|
||
<div class="approval-card">
|
||
<div class="approval-action">${escHtml(a.action)}</div>
|
||
<div class="approval-reason">${escHtml(a.reason || 'Approval required')}</div>
|
||
<div class="approval-buttons">
|
||
<button class="btn btn-sm btn-green" onclick="resolveApproval('${a.id}','approved')">? Approve</button>
|
||
<button class="btn btn-sm btn-red" onclick="resolveApproval('${a.id}','rejected')">? Reject</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} catch {}
|
||
}
|
||
|
||
async function resolveApproval(id, decision) {
|
||
await api(`/api/approvals/${id}`, { method: 'POST', body: JSON.stringify({ decision }) });
|
||
loadApprovals();
|
||
}
|
||
|
||
function fmtPercent(v, digits = 0) {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return '--%';
|
||
return `${n.toFixed(digits)}%`;
|
||
}
|
||
|
||
function fmtMemoryGb(usedGb, totalGb) {
|
||
const u = Number(usedGb);
|
||
const t = Number(totalGb);
|
||
if (!Number.isFinite(u) || !Number.isFinite(t) || t <= 0) return '-- / -- GB';
|
||
return `${u.toFixed(1)} / ${t.toFixed(1)} GB`;
|
||
}
|
||
|
||
function meterWidth(pct) {
|
||
const n = Number(pct);
|
||
if (!Number.isFinite(n)) return '0%';
|
||
const bounded = Math.max(0, Math.min(100, n));
|
||
return `${bounded}%`;
|
||
}
|
||
|
||
function setText(id, text) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = String(text || '');
|
||
}
|
||
|
||
function setMeter(id, pct) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.style.width = meterWidth(pct);
|
||
}
|
||
|
||
function fmtNetSpeed(bps) {
|
||
if (bps >= 1024 * 1024 * 1024) return (bps / (1024 ** 3)).toFixed(1) + ' GB/s';
|
||
if (bps >= 1024 * 1024) return (bps / (1024 ** 2)).toFixed(1) + ' MB/s';
|
||
if (bps >= 1024) return (bps / 1024).toFixed(0) + ' KB/s';
|
||
return bps.toFixed(0) + ' B/s';
|
||
}
|
||
|
||
// 네트워크 바: 최근 최댓값 기준으로 스케일
|
||
let _netPeak = 1024 * 1024; // 초기 1 MB/s 기준
|
||
function netBarPct(bps) {
|
||
if (bps > _netPeak) _netPeak = bps;
|
||
return Math.min(100, (bps / _netPeak) * 100);
|
||
}
|
||
|
||
let systemStatsFetchInFlight = false;
|
||
async function loadSystemStats() {
|
||
if (systemStatsFetchInFlight) return;
|
||
systemStatsFetchInFlight = true;
|
||
try {
|
||
const stats = await api('/api/system-stats');
|
||
const cpu = stats?.system?.cpu_percent;
|
||
const ram = stats?.system?.memory_percent;
|
||
const gpu = stats?.gpu?.gpu_util_percent;
|
||
const vram = stats?.gpu?.vram_used_percent;
|
||
const gpuAvailable = !!stats?.gpu?.available;
|
||
|
||
setText('quick-system-stats', `CPU ${fmtPercent(cpu)} • RAM ${fmtPercent(ram)} • GPU ${gpuAvailable ? fmtPercent(gpu) : '--%'}`);
|
||
|
||
setText('sys-cpu', fmtPercent(cpu));
|
||
setText('sys-ram', fmtPercent(ram));
|
||
setText('sys-gpu', gpuAvailable ? fmtPercent(gpu) : 'N/A');
|
||
setText('sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
|
||
setMeter('sys-cpu-meter', cpu);
|
||
setMeter('sys-ram-meter', ram);
|
||
setMeter('sys-gpu-meter', gpuAvailable ? gpu : 0);
|
||
setMeter('sys-vram-meter', gpuAvailable ? vram : 0);
|
||
|
||
// 사이드바 리소스 바
|
||
setText('sb-sys-cpu', fmtPercent(cpu));
|
||
setText('sb-sys-ram', fmtPercent(ram));
|
||
setText('sb-sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
|
||
setMeter('sb-sys-cpu-meter', cpu);
|
||
setMeter('sb-sys-ram-meter', ram);
|
||
setMeter('sb-sys-vram-meter', gpuAvailable ? vram : 0);
|
||
const rxBps = stats?.network?.rx_bps || 0;
|
||
const txBps = stats?.network?.tx_bps || 0;
|
||
setText('sb-sys-rx', fmtNetSpeed(rxBps));
|
||
setText('sb-sys-tx', fmtNetSpeed(txBps));
|
||
setMeter('sb-sys-rx-meter', netBarPct(rxBps));
|
||
setMeter('sb-sys-tx-meter', netBarPct(txBps));
|
||
|
||
const ollamaRunning = !!stats?.ollama_process?.running;
|
||
const ollamaCount = Number(stats?.ollama_process?.process_count || 0);
|
||
const ollamaMemMb = Number(stats?.ollama_process?.total_memory_mb || 0);
|
||
const gatewayRss = Number(stats?.gateway_process?.rss_mb || 0);
|
||
const gpuName = String(stats?.gpu?.name || '').trim();
|
||
const memUsage = fmtMemoryGb(stats?.system?.memory_used_gb, stats?.system?.memory_total_gb);
|
||
const updated = new Date(stats?.timestamp || Date.now()).toLocaleTimeString();
|
||
const activeProvider = stats?.active_provider || 'ollama';
|
||
const activeModel = stats?.active_model || document.getElementById('current-model')?.textContent || '-';
|
||
const isLocalProvider = activeProvider === 'ollama' || activeProvider === 'llama_cpp' || activeProvider === 'lm_studio';
|
||
|
||
// Show provider + model in the runtime card
|
||
const providerDisp = (PROVIDER_LABELS[activeProvider] || activeProvider);
|
||
let runtimeLabel;
|
||
if (activeProvider === 'ollama') {
|
||
runtimeLabel = ollamaRunning ? `Ollama • ${activeModel}` : `Ollama offline • ${activeModel}`;
|
||
} else {
|
||
runtimeLabel = `${providerDisp} • ${activeModel}`;
|
||
}
|
||
setText('sys-ollama', runtimeLabel);
|
||
const memPart = isLocalProvider && ollamaMemMb > 0 ? ` • LLM ${ollamaMemMb.toFixed(0)} MB` : '';
|
||
setText(
|
||
'sys-runtime-sub',
|
||
`${memUsage}${memPart} • Gateway ${gatewayRss.toFixed(0)} MB${gpuName ? ` • ${gpuName}` : ''} • ${updated}`
|
||
);
|
||
|
||
const onlineUsers = Array.isArray(stats?.online_users) ? stats.online_users : [];
|
||
const onlineEl = document.getElementById('sys-online-users');
|
||
if (onlineEl) {
|
||
if (onlineUsers.length === 0) {
|
||
onlineEl.textContent = '없음';
|
||
onlineEl.style.color = 'var(--muted)';
|
||
} else {
|
||
onlineEl.innerHTML = onlineUsers.map(u =>
|
||
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok)"></span>${escHtml(String(u))}</span>`
|
||
).join('');
|
||
onlineEl.style.color = 'var(--text)';
|
||
}
|
||
}
|
||
} catch {
|
||
setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%');
|
||
setText('sys-cpu', '--%');
|
||
setText('sys-ram', '--%');
|
||
setText('sys-gpu', 'N/A');
|
||
setText('sys-vram', 'N/A');
|
||
setText('sys-ollama', 'Stats unavailable');
|
||
setText('sys-runtime-sub', 'Could not fetch system stats right now');
|
||
setMeter('sys-cpu-meter', 0);
|
||
setMeter('sys-ram-meter', 0);
|
||
setMeter('sys-gpu-meter', 0);
|
||
setMeter('sys-vram-meter', 0);
|
||
setText('sb-sys-cpu', '--%');
|
||
setText('sb-sys-ram', '--%');
|
||
setText('sb-sys-vram', 'N/A');
|
||
setMeter('sb-sys-cpu-meter', 0);
|
||
setMeter('sb-sys-ram-meter', 0);
|
||
setMeter('sb-sys-vram-meter', 0);
|
||
setText('sb-sys-rx', '0 B/s');
|
||
setText('sb-sys-tx', '0 B/s');
|
||
setMeter('sb-sys-rx-meter', 0);
|
||
setMeter('sb-sys-tx-meter', 0);
|
||
} finally {
|
||
systemStatsFetchInFlight = false;
|
||
}
|
||
}
|
||
|
||
// ---- Status ----
|
||
const PROVIDER_LABELS = {
|
||
ollama: 'Ollama', llama_cpp: 'llama.cpp', lm_studio: 'LM Studio',
|
||
openai: 'OpenAI', openai_codex: 'ChatGPT'
|
||
};
|
||
async function checkStatus() {
|
||
try {
|
||
const status = await api('/api/status');
|
||
const provider = status.provider || 'ollama';
|
||
const providerLabel = PROVIDER_LABELS[provider] || provider;
|
||
const isCloud = provider === 'openai' || provider === 'openai_codex';
|
||
const dot = document.getElementById('ollama-dot');
|
||
|
||
// For cloud providers, always show as online if gateway is up
|
||
const backendOnline = isCloud ? true : !!status.ollama;
|
||
dot.className = 'dot ' + (backendOnline ? 'online' : 'offline');
|
||
document.getElementById('ollama-status').textContent = backendOnline
|
||
? `${providerLabel} Online` : `${providerLabel} Offline`;
|
||
const ro = document.getElementById('r-ollama');
|
||
if (ro) {
|
||
ro.textContent = backendOnline ? 'Online' : 'Offline';
|
||
ro.className = 'info-val ' + (backendOnline ? 'green' : 'red');
|
||
}
|
||
|
||
const modelName = status.currentModel || '-';
|
||
const rm = document.getElementById('r-model');
|
||
if (rm) rm.textContent = modelName;
|
||
document.getElementById('current-model').textContent = modelName;
|
||
document.getElementById('chat-model-name').textContent = modelName;
|
||
const badge = document.getElementById('current-provider-badge');
|
||
if (badge) badge.textContent = isCloud ? providerLabel.toUpperCase() : '';
|
||
const cpn = document.getElementById('chat-provider-name');
|
||
if (cpn) cpn.textContent = providerLabel;
|
||
} catch {
|
||
document.getElementById('ollama-dot').className = 'dot offline';
|
||
}
|
||
}
|
||
|
||
// --- Orchestration Settings -------------------------------------------------
|
||
|
||
const SECONDARY_MODEL_DEFAULTS = {
|
||
openai_codex: 'gpt-5.3-codex',
|
||
openai: 'gpt-4o',
|
||
ollama: '',
|
||
llama_cpp: '',
|
||
lm_studio: '',
|
||
};
|
||
|
||
const ORCH_STATIC_MODEL_OPTIONS = {
|
||
openai_codex: ['gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2-codex', 'gpt-5.1-codex-mini'],
|
||
openai: ['gpt-4o', 'gpt-4o-mini', 'o4-mini', 'o3'],
|
||
};
|
||
|
||
function getSelectOptionValues(selectId) {
|
||
const sel = document.getElementById(selectId);
|
||
if (!sel) return [];
|
||
return Array.from(sel.options || []).map(o => String(o.value || '').trim()).filter(Boolean);
|
||
}
|
||
|
||
function getDraftModelForProvider(provider) {
|
||
if (provider === 'ollama') {
|
||
return String(document.getElementById('settings-primary-model')?.value || '').trim();
|
||
}
|
||
if (provider === 'llama_cpp') {
|
||
return String(document.getElementById('settings-llamacpp-model')?.value || '').trim();
|
||
}
|
||
if (provider === 'lm_studio') {
|
||
return String(document.getElementById('settings-lmstudio-model')?.value || '').trim();
|
||
}
|
||
if (provider === 'openai') {
|
||
return String(document.getElementById('settings-openai-model')?.value || '').trim();
|
||
}
|
||
if (provider === 'openai_codex') {
|
||
return String(document.getElementById('settings-codex-model')?.value || '').trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function uniqueStrings(values) {
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const v of values || []) {
|
||
const s = String(v || '').trim();
|
||
if (!s || seen.has(s)) continue;
|
||
seen.add(s);
|
||
out.push(s);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function updateOrchSecondaryModels(preferredModel = '') {
|
||
const provider = document.getElementById('orch-secondary-provider')?.value || '';
|
||
const modelSelect = document.getElementById('orch-secondary-model');
|
||
if (!modelSelect) return;
|
||
|
||
if (!provider) {
|
||
modelSelect.innerHTML = '<option value="">— select provider first —</option>';
|
||
modelSelect.value = '';
|
||
return;
|
||
}
|
||
|
||
let models = [];
|
||
const draftModel = getDraftModelForProvider(provider);
|
||
if (draftModel) models.push(draftModel);
|
||
|
||
if (provider === 'openai') {
|
||
try { await refreshOpenAIModels(true); } catch {}
|
||
models.push(...getSelectOptionValues('settings-openai-model'));
|
||
} else if (provider === 'openai_codex') {
|
||
models.push(...getSelectOptionValues('settings-codex-model'));
|
||
} else if (['ollama', 'llama_cpp', 'lm_studio'].includes(provider)) {
|
||
try {
|
||
const llm = buildProviderPayload();
|
||
llm.provider = provider;
|
||
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
|
||
const detected = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
|
||
models.push(...detected);
|
||
} catch {}
|
||
}
|
||
|
||
models = uniqueStrings([...(ORCH_STATIC_MODEL_OPTIONS[provider] || []), ...models]);
|
||
if (preferredModel && !models.includes(preferredModel)) {
|
||
models.unshift(preferredModel);
|
||
}
|
||
if (!models.length && SECONDARY_MODEL_DEFAULTS[provider]) {
|
||
models.push(SECONDARY_MODEL_DEFAULTS[provider]);
|
||
}
|
||
|
||
if (!models.length) {
|
||
modelSelect.innerHTML = '<option value="">— 감지된 모델 없음 —</option>';
|
||
modelSelect.value = '';
|
||
return;
|
||
}
|
||
|
||
modelSelect.innerHTML = models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
|
||
const selected = preferredModel || modelSelect.value || draftModel || SECONDARY_MODEL_DEFAULTS[provider] || models[0];
|
||
modelSelect.value = models.includes(selected) ? selected : models[0];
|
||
}
|
||
|
||
let orchActivePreset = '';
|
||
|
||
const ORCH_PRESETS = {
|
||
conservative: {
|
||
desc: '오케스트레이터가 최소한으로 개입 — 안정적이지만 느리게 반응',
|
||
fail_threshold: 3, stagnation: 5, loop_detect: 'true',
|
||
risk_files: 10, risk_ops: 400, no_progress_sec: 150,
|
||
preflight: 'off', allow_secondary_chat: 'false',
|
||
cooldown: 5, max_assists_turn: 2, max_assists_session: 10,
|
||
browser_calls_turn: 3, browser_max_items: 40, browser_forced_retries: 0,
|
||
preempt_enabled: 'false', preempt_threshold: 90, preempt_max_turn: 1, preempt_max_session: 1,
|
||
fileops_enabled: 'true', fileops_verify_create: 'false',
|
||
fileops_create_lines: 150, fileops_create_chars: 7000,
|
||
fileops_edit_lines: 25, fileops_edit_chars: 2000, fileops_edit_files: 2,
|
||
fileops_verify_lines: 50, fileops_verify_chars: 2500, fileops_watchdog: 5, fileops_checkpointing: 'true',
|
||
},
|
||
balanced: {
|
||
desc: '기본 권장값 — 대부분의 상황에 적합',
|
||
fail_threshold: 2, stagnation: 3, loop_detect: 'true',
|
||
risk_files: 6, risk_ops: 220, no_progress_sec: 90,
|
||
preflight: 'complex_only', allow_secondary_chat: 'false',
|
||
cooldown: 3, max_assists_turn: 3, max_assists_session: 18,
|
||
browser_calls_turn: 5, browser_max_items: 80, browser_forced_retries: 0,
|
||
preempt_enabled: 'false', preempt_threshold: 45, preempt_max_turn: 1, preempt_max_session: 3,
|
||
fileops_enabled: 'true', fileops_verify_create: 'true',
|
||
fileops_create_lines: 80, fileops_create_chars: 3500,
|
||
fileops_edit_lines: 12, fileops_edit_chars: 800, fileops_edit_files: 1,
|
||
fileops_verify_lines: 25, fileops_verify_chars: 1200, fileops_watchdog: 3, fileops_checkpointing: 'true',
|
||
},
|
||
aggressive: {
|
||
desc: '자주 적극적으로 개입 — 복잡한 멀티스텝 작업에 적합',
|
||
fail_threshold: 1, stagnation: 2, loop_detect: 'true',
|
||
risk_files: 3, risk_ops: 100, no_progress_sec: 45,
|
||
preflight: 'always', allow_secondary_chat: 'false',
|
||
cooldown: 2, max_assists_turn: 5, max_assists_session: 30,
|
||
browser_calls_turn: 8, browser_max_items: 120, browser_forced_retries: 2,
|
||
preempt_enabled: 'true', preempt_threshold: 30, preempt_max_turn: 2, preempt_max_session: 6,
|
||
fileops_enabled: 'true', fileops_verify_create: 'true',
|
||
fileops_create_lines: 40, fileops_create_chars: 1500,
|
||
fileops_edit_lines: 6, fileops_edit_chars: 400, fileops_edit_files: 1,
|
||
fileops_verify_lines: 15, fileops_verify_chars: 600, fileops_watchdog: 2, fileops_checkpointing: 'true',
|
||
},
|
||
};
|
||
|
||
function applyOrchPreset(name) {
|
||
const p = ORCH_PRESETS[name];
|
||
if (!p) return;
|
||
orchActivePreset = name;
|
||
|
||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
|
||
set('orch-fail-threshold', p.fail_threshold);
|
||
set('orch-stagnation', p.stagnation);
|
||
set('orch-loop-detect', p.loop_detect);
|
||
set('orch-risk-files', p.risk_files);
|
||
set('orch-risk-ops', p.risk_ops);
|
||
set('orch-no-progress-sec', p.no_progress_sec);
|
||
set('orch-preflight-mode', p.preflight);
|
||
set('orch-allow-secondary-chat', p.allow_secondary_chat);
|
||
set('orch-cooldown-rounds', p.cooldown);
|
||
set('orch-max-assists-turn', p.max_assists_turn);
|
||
set('orch-max-assists-session', p.max_assists_session);
|
||
set('orch-browser-calls-turn', p.browser_calls_turn);
|
||
set('orch-browser-max-items', p.browser_max_items);
|
||
set('orch-browser-forced-retries', p.browser_forced_retries);
|
||
set('orch-preempt-enabled', p.preempt_enabled);
|
||
set('orch-preempt-threshold', p.preempt_threshold);
|
||
set('orch-preempt-max-turn', p.preempt_max_turn);
|
||
set('orch-preempt-max-session', p.preempt_max_session);
|
||
set('orch-fileops-enabled', p.fileops_enabled);
|
||
set('orch-fileops-verify-create', p.fileops_verify_create);
|
||
set('orch-fileops-create-lines', p.fileops_create_lines);
|
||
set('orch-fileops-create-chars', p.fileops_create_chars);
|
||
set('orch-fileops-edit-lines', p.fileops_edit_lines);
|
||
set('orch-fileops-edit-chars', p.fileops_edit_chars);
|
||
set('orch-fileops-edit-files', p.fileops_edit_files);
|
||
set('orch-fileops-verify-lines', p.fileops_verify_lines);
|
||
set('orch-fileops-verify-chars', p.fileops_verify_chars);
|
||
set('orch-fileops-watchdog-cycles', p.fileops_watchdog);
|
||
set('orch-fileops-checkpointing', p.fileops_checkpointing);
|
||
|
||
// highlight active button
|
||
['conservative','balanced','aggressive'].forEach(n => {
|
||
const btn = document.getElementById('orch-preset-' + n);
|
||
if (!btn) return;
|
||
btn.style.background = n === name ? '#eaf2ff' : '#fff';
|
||
btn.style.borderColor = n === name ? '#0d4faf' : 'var(--line)';
|
||
btn.style.color = n === name ? '#0d4faf' : '#5a6e8a';
|
||
});
|
||
const desc = document.getElementById('orch-preset-desc');
|
||
if (desc) desc.textContent = p.desc;
|
||
}
|
||
|
||
function detectOrchPreset(cfg) {
|
||
const t = cfg.triggers || {};
|
||
const l = cfg.limits || {};
|
||
if (t.consecutive_failures <= 1 && t.stagnation_rounds <= 2) return 'aggressive';
|
||
if (t.consecutive_failures >= 3 && t.stagnation_rounds >= 5) return 'conservative';
|
||
return 'balanced';
|
||
}
|
||
|
||
function toggleOrchAdvanced() {
|
||
const panel = document.getElementById('orch-advanced-panel');
|
||
const arrow = document.getElementById('orch-advanced-arrow');
|
||
if (!panel) return;
|
||
const open = panel.style.display !== 'none';
|
||
panel.style.display = open ? 'none' : 'block';
|
||
if (arrow) arrow.style.transform = open ? '' : 'rotate(90deg)';
|
||
}
|
||
|
||
// ── Voice Settings ───────────────────────────────────────────────────────────
|
||
|
||
async function loadVoiceSettings() {
|
||
try {
|
||
const cfg = await api('/api/settings/voice');
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
|
||
v('voice-stt-provider', cfg.stt?.provider);
|
||
v('voice-stt-model', cfg.stt?.model);
|
||
v('voice-stt-language', cfg.stt?.language);
|
||
v('voice-tts-provider', cfg.tts?.provider);
|
||
v('voice-tts-model', cfg.tts?.model);
|
||
} catch (e) { console.warn('loadVoiceSettings:', e); }
|
||
}
|
||
|
||
async function saveVoiceSettings() {
|
||
const body = {
|
||
stt: {
|
||
provider: document.getElementById('voice-stt-provider')?.value,
|
||
model: document.getElementById('voice-stt-model')?.value?.trim(),
|
||
language: document.getElementById('voice-stt-language')?.value?.trim(),
|
||
},
|
||
tts: {
|
||
provider: document.getElementById('voice-tts-provider')?.value,
|
||
model: document.getElementById('voice-tts-model')?.value?.trim(),
|
||
},
|
||
};
|
||
try {
|
||
await api('/api/settings/voice', { method: 'POST', body: JSON.stringify(body) });
|
||
addProcessEntry('final', '음성 설정이 저장되었습니다.');
|
||
} catch (err) {
|
||
addProcessEntry('error', '음성 설정 저장 실패: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ── Session Settings ─────────────────────────────────────────────────────────
|
||
|
||
async function loadSessionSettings() {
|
||
try {
|
||
const cfg = await api('/api/settings/session');
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = String(val); };
|
||
v('session-max-messages', cfg.maxMessages);
|
||
v('session-history-turns', cfg.historyTurns);
|
||
v('session-compaction', cfg.compactionThreshold);
|
||
v('session-memory-flush', cfg.memoryFlushThreshold);
|
||
} catch (e) { console.warn('loadSessionSettings:', e); }
|
||
}
|
||
|
||
async function saveSessionSettings() {
|
||
const body = {
|
||
maxMessages: Number(document.getElementById('session-max-messages')?.value || 120),
|
||
historyTurns: Number(document.getElementById('session-history-turns')?.value || 8),
|
||
compactionThreshold: Number(document.getElementById('session-compaction')?.value || 0.7),
|
||
memoryFlushThreshold: Number(document.getElementById('session-memory-flush')?.value || 0.75),
|
||
};
|
||
try {
|
||
await api('/api/settings/session', { method: 'POST', body: JSON.stringify(body) });
|
||
addProcessEntry('final', '세션 설정이 저장되었습니다.');
|
||
} catch (err) {
|
||
addProcessEntry('error', '세션 설정 저장 실패: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function loadOrchestrationConfig() {
|
||
try {
|
||
const cfg = await api('/api/orchestration/config');
|
||
const provEl = document.getElementById('orch-secondary-provider');
|
||
const failEl = document.getElementById('orch-fail-threshold');
|
||
const stagEl = document.getElementById('orch-stagnation');
|
||
const loopEl = document.getElementById('orch-loop-detect');
|
||
const riskFilesEl = document.getElementById('orch-risk-files');
|
||
const riskOpsEl = document.getElementById('orch-risk-ops');
|
||
const noProgressEl = document.getElementById('orch-no-progress-sec');
|
||
const preflightModeEl = document.getElementById('orch-preflight-mode');
|
||
const allowSecondaryChatEl = document.getElementById('orch-allow-secondary-chat');
|
||
const cooldownEl = document.getElementById('orch-cooldown-rounds');
|
||
const maxAssistTurnEl = document.getElementById('orch-max-assists-turn');
|
||
const maxAssistSessionEl = document.getElementById('orch-max-assists-session');
|
||
const browserCallsEl = document.getElementById('orch-browser-calls-turn');
|
||
const browserItemsEl = document.getElementById('orch-browser-max-items');
|
||
const browserRetriesEl = document.getElementById('orch-browser-forced-retries');
|
||
const preemptEnabledEl = document.getElementById('orch-preempt-enabled');
|
||
const preemptThresholdEl = document.getElementById('orch-preempt-threshold');
|
||
const preemptMaxTurnEl = document.getElementById('orch-preempt-max-turn');
|
||
const preemptMaxSessionEl = document.getElementById('orch-preempt-max-session');
|
||
const fileOpsEnabledEl = document.getElementById('orch-fileops-enabled');
|
||
const fileOpsVerifyCreateEl = document.getElementById('orch-fileops-verify-create');
|
||
const fileOpsCreateLinesEl = document.getElementById('orch-fileops-create-lines');
|
||
const fileOpsCreateCharsEl = document.getElementById('orch-fileops-create-chars');
|
||
const fileOpsEditLinesEl = document.getElementById('orch-fileops-edit-lines');
|
||
const fileOpsEditCharsEl = document.getElementById('orch-fileops-edit-chars');
|
||
const fileOpsEditFilesEl = document.getElementById('orch-fileops-edit-files');
|
||
const fileOpsVerifyLinesEl = document.getElementById('orch-fileops-verify-lines');
|
||
const fileOpsVerifyCharsEl = document.getElementById('orch-fileops-verify-chars');
|
||
const fileOpsWatchdogCyclesEl = document.getElementById('orch-fileops-watchdog-cycles');
|
||
const fileOpsCheckpointingEl = document.getElementById('orch-fileops-checkpointing');
|
||
if (provEl) provEl.value = cfg.secondary?.provider || '';
|
||
await updateOrchSecondaryModels(cfg.secondary?.model || '');
|
||
if (failEl) failEl.value = String(cfg.triggers?.consecutive_failures ?? 2);
|
||
if (stagEl) stagEl.value = String(cfg.triggers?.stagnation_rounds ?? 3);
|
||
if (loopEl) loopEl.value = String(cfg.triggers?.loop_detection ?? true);
|
||
if (riskFilesEl) riskFilesEl.value = String(cfg.triggers?.risky_files_threshold ?? 6);
|
||
if (riskOpsEl) riskOpsEl.value = String(cfg.triggers?.risky_tool_ops_threshold ?? 220);
|
||
if (noProgressEl) noProgressEl.value = String(cfg.triggers?.no_progress_seconds ?? 90);
|
||
if (preflightModeEl) preflightModeEl.value = String(cfg.preflight?.mode || 'complex_only');
|
||
if (allowSecondaryChatEl) allowSecondaryChatEl.value = String(cfg.preflight?.allow_secondary_chat === true);
|
||
if (cooldownEl) cooldownEl.value = String(cfg.limits?.assist_cooldown_rounds ?? 3);
|
||
if (maxAssistTurnEl) maxAssistTurnEl.value = String(cfg.limits?.max_assists_per_turn ?? 3);
|
||
if (maxAssistSessionEl) maxAssistSessionEl.value = String(cfg.limits?.max_assists_per_session ?? 18);
|
||
if (browserCallsEl) browserCallsEl.value = String(cfg.browser?.max_advisor_calls_per_turn ?? 5);
|
||
if (browserItemsEl) browserItemsEl.value = String(cfg.browser?.max_collected_items ?? 80);
|
||
if (browserRetriesEl) browserRetriesEl.value = String(cfg.browser?.max_forced_retries ?? 2);
|
||
if (preemptEnabledEl) preemptEnabledEl.value = String(cfg.preempt?.enabled === true);
|
||
if (preemptThresholdEl) preemptThresholdEl.value = String(cfg.preempt?.stall_threshold_seconds ?? 45);
|
||
if (preemptMaxTurnEl) preemptMaxTurnEl.value = String(cfg.preempt?.max_preempts_per_turn ?? 1);
|
||
if (preemptMaxSessionEl) preemptMaxSessionEl.value = String(cfg.preempt?.max_preempts_per_session ?? 3);
|
||
if (fileOpsEnabledEl) fileOpsEnabledEl.value = String(cfg.file_ops?.enabled !== false);
|
||
if (fileOpsVerifyCreateEl) fileOpsVerifyCreateEl.value = String(cfg.file_ops?.verify_create_always !== false);
|
||
if (fileOpsCreateLinesEl) fileOpsCreateLinesEl.value = String(cfg.file_ops?.primary_create_max_lines ?? 80);
|
||
if (fileOpsCreateCharsEl) fileOpsCreateCharsEl.value = String(cfg.file_ops?.primary_create_max_chars ?? 3500);
|
||
if (fileOpsEditLinesEl) fileOpsEditLinesEl.value = String(cfg.file_ops?.primary_edit_max_lines ?? 12);
|
||
if (fileOpsEditCharsEl) fileOpsEditCharsEl.value = String(cfg.file_ops?.primary_edit_max_chars ?? 800);
|
||
if (fileOpsEditFilesEl) fileOpsEditFilesEl.value = String(cfg.file_ops?.primary_edit_max_files ?? 1);
|
||
if (fileOpsVerifyLinesEl) fileOpsVerifyLinesEl.value = String(cfg.file_ops?.verify_large_payload_lines ?? 25);
|
||
if (fileOpsVerifyCharsEl) fileOpsVerifyCharsEl.value = String(cfg.file_ops?.verify_large_payload_chars ?? 1200);
|
||
if (fileOpsWatchdogCyclesEl) fileOpsWatchdogCyclesEl.value = String(cfg.file_ops?.watchdog_no_progress_cycles ?? 3);
|
||
if (fileOpsCheckpointingEl) fileOpsCheckpointingEl.value = String(cfg.file_ops?.checkpointing_enabled !== false);
|
||
syncOrchPreemptControls();
|
||
// Auto-detect and highlight matching preset
|
||
applyOrchPreset(detectOrchPreset(cfg));
|
||
// Check eligibility
|
||
const elig = await api('/api/orchestration/eligible');
|
||
const msg = document.getElementById('orch-eligibility-msg');
|
||
if (msg) {
|
||
if (elig.eligible) {
|
||
msg.textContent = '? Orchestration eligible — enable the skill in the Skills tab.';
|
||
msg.style.color = 'var(--green, #4ade80)';
|
||
} else {
|
||
msg.textContent = elig.reason || 'Not eligible.';
|
||
msg.style.color = 'var(--muted)';
|
||
}
|
||
msg.style.display = 'block';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function saveOrchestrationConfig() {
|
||
const provider = document.getElementById('orch-secondary-provider')?.value || '';
|
||
const model = document.getElementById('orch-secondary-model')?.value || '';
|
||
const failT = Number(document.getElementById('orch-fail-threshold')?.value || 2);
|
||
const stagT = Number(document.getElementById('orch-stagnation')?.value || 3);
|
||
const loopD = document.getElementById('orch-loop-detect')?.value !== 'false';
|
||
const riskFiles = Number(document.getElementById('orch-risk-files')?.value || 6);
|
||
const riskOps = Number(document.getElementById('orch-risk-ops')?.value || 220);
|
||
const noProgressSeconds = Number(document.getElementById('orch-no-progress-sec')?.value || 90);
|
||
const preflightMode = document.getElementById('orch-preflight-mode')?.value || 'complex_only';
|
||
const allowSecondaryChat = document.getElementById('orch-allow-secondary-chat')?.value === 'true';
|
||
const cooldownRounds = Number(document.getElementById('orch-cooldown-rounds')?.value || 3);
|
||
const maxAssistsTurn = Number(document.getElementById('orch-max-assists-turn')?.value || 3);
|
||
const maxAssistsSession = Number(document.getElementById('orch-max-assists-session')?.value || 18);
|
||
const browserCallsPerTurn = Number(document.getElementById('orch-browser-calls-turn')?.value || 5);
|
||
const browserMaxItems = Number(document.getElementById('orch-browser-max-items')?.value || 80);
|
||
const browserForcedRetries = Number(document.getElementById('orch-browser-forced-retries')?.value || 2);
|
||
const preemptEnabled = document.getElementById('orch-preempt-enabled')?.value === 'true';
|
||
const preemptThresholdSec = Number(document.getElementById('orch-preempt-threshold')?.value || 45);
|
||
const preemptMaxTurn = Number(document.getElementById('orch-preempt-max-turn')?.value || 1);
|
||
const preemptMaxSession = Number(document.getElementById('orch-preempt-max-session')?.value || 3);
|
||
const fileOpsEnabled = document.getElementById('orch-fileops-enabled')?.value !== 'false';
|
||
const fileOpsVerifyCreate = document.getElementById('orch-fileops-verify-create')?.value !== 'false';
|
||
const fileOpsCreateLines = Number(document.getElementById('orch-fileops-create-lines')?.value || 80);
|
||
const fileOpsCreateChars = Number(document.getElementById('orch-fileops-create-chars')?.value || 3500);
|
||
const fileOpsEditLines = Number(document.getElementById('orch-fileops-edit-lines')?.value || 12);
|
||
const fileOpsEditChars = Number(document.getElementById('orch-fileops-edit-chars')?.value || 800);
|
||
const fileOpsEditFiles = Number(document.getElementById('orch-fileops-edit-files')?.value || 1);
|
||
const fileOpsVerifyLines = Number(document.getElementById('orch-fileops-verify-lines')?.value || 25);
|
||
const fileOpsVerifyChars = Number(document.getElementById('orch-fileops-verify-chars')?.value || 1200);
|
||
const fileOpsWatchdogCycles = Number(document.getElementById('orch-fileops-watchdog-cycles')?.value || 3);
|
||
const fileOpsCheckpointing = document.getElementById('orch-fileops-checkpointing')?.value !== 'false';
|
||
try {
|
||
await api('/api/orchestration/config', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
secondary: { provider, model },
|
||
triggers: {
|
||
consecutive_failures: failT,
|
||
stagnation_rounds: stagT,
|
||
loop_detection: loopD,
|
||
risky_files_threshold: riskFiles,
|
||
risky_tool_ops_threshold: riskOps,
|
||
no_progress_seconds: noProgressSeconds,
|
||
},
|
||
preflight: { mode: preflightMode, allow_secondary_chat: allowSecondaryChat },
|
||
limits: {
|
||
assist_cooldown_rounds: cooldownRounds,
|
||
max_assists_per_turn: maxAssistsTurn,
|
||
max_assists_per_session: maxAssistsSession,
|
||
},
|
||
browser: {
|
||
max_advisor_calls_per_turn: browserCallsPerTurn,
|
||
max_collected_items: browserMaxItems,
|
||
max_forced_retries: browserForcedRetries,
|
||
},
|
||
preempt: {
|
||
enabled: preemptEnabled,
|
||
stall_threshold_seconds: preemptThresholdSec,
|
||
max_preempts_per_turn: preemptMaxTurn,
|
||
max_preempts_per_session: preemptMaxSession,
|
||
},
|
||
file_ops: {
|
||
enabled: fileOpsEnabled,
|
||
primary_create_max_lines: fileOpsCreateLines,
|
||
primary_create_max_chars: fileOpsCreateChars,
|
||
primary_edit_max_lines: fileOpsEditLines,
|
||
primary_edit_max_chars: fileOpsEditChars,
|
||
primary_edit_max_files: fileOpsEditFiles,
|
||
verify_create_always: fileOpsVerifyCreate,
|
||
verify_large_payload_lines: fileOpsVerifyLines,
|
||
verify_large_payload_chars: fileOpsVerifyChars,
|
||
watchdog_no_progress_cycles: fileOpsWatchdogCycles,
|
||
checkpointing_enabled: fileOpsCheckpointing,
|
||
},
|
||
}),
|
||
});
|
||
const msg = document.getElementById('orch-eligibility-msg');
|
||
if (msg) {
|
||
msg.textContent = '? Saved. Now enable the Multi-Agent Orchestrator skill in the Skills tab.';
|
||
msg.style.color = 'var(--green, #4ade80)';
|
||
msg.style.display = 'block';
|
||
}
|
||
loadOrchestrationConfig(); // refresh eligibility
|
||
loadInstalledSkills(); // refresh skill gating state
|
||
} catch (err) {
|
||
alert('Failed to save: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
function syncOrchPreemptControls() {
|
||
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
|
||
const note = document.getElementById('orch-preempt-note');
|
||
const fields = [
|
||
document.getElementById('orch-preempt-enabled'),
|
||
document.getElementById('orch-preempt-threshold'),
|
||
document.getElementById('orch-preempt-max-turn'),
|
||
document.getElementById('orch-preempt-max-session'),
|
||
].filter(Boolean);
|
||
const ollamaPrimary = provider === 'ollama';
|
||
fields.forEach(el => { el.disabled = !ollamaPrimary; });
|
||
if (note) note.style.display = ollamaPrimary ? 'none' : 'block';
|
||
}
|
||
|
||
// --- Skill card eligibility rendering ---------------------------------------
|
||
|
||
function renderSkillEligibility(skills) {
|
||
// Called after skills render — grey out ineligible skill cards
|
||
setTimeout(() => {
|
||
document.querySelectorAll('.skill-card').forEach(card => {
|
||
const skillId = card.dataset.skillId;
|
||
const skill = skills.find(s => s.id === skillId);
|
||
if (!skill) return;
|
||
if (skill.eligible === false) {
|
||
card.style.opacity = '0.5';
|
||
// Keep card clickable when already enabled so user can still disable it.
|
||
card.style.pointerEvents = skill.enabled ? 'auto' : 'none';
|
||
card.title = skill.eligibleReason || 'Not available';
|
||
const badge = document.createElement('div');
|
||
badge.textContent = skill.eligibleReason || 'Configure secondary model first';
|
||
badge.style = 'font-size:10px;color:var(--muted);margin-top:4px;';
|
||
if (!card.querySelector('.elig-badge')) {
|
||
badge.className = 'elig-badge';
|
||
card.appendChild(badge);
|
||
}
|
||
}
|
||
});
|
||
}, 50);
|
||
}
|
||
|
||
// --- Handle orchestration SSE events ----------------------------------------
|
||
|
||
function handleOrchestrationEvent(data) {
|
||
const panel =
|
||
document.getElementById('tool-calls-panel')
|
||
|| document.getElementById('steps-panel')
|
||
|| document.getElementById('current-turn-process')
|
||
|| document.getElementById('process-log');
|
||
if (!panel) return;
|
||
const el = document.createElement('div');
|
||
el.style = 'padding:6px 10px;margin:4px 0;background:linear-gradient(135deg,#1a1a2e,#16213e);border-left:3px solid #7c3aed;border-radius:4px;font-size:12px;color:#a78bfa;';
|
||
const modeLabel = data.mode === 'planner' ? 'Planner' : 'Rescue';
|
||
const routeText = data.route ? ` | route=${data.route}` : '';
|
||
const countText = (typeof data.assist_count === 'number' && typeof data.assist_cap === 'number')
|
||
? ` | assists ${data.assist_count}/${data.assist_cap}`
|
||
: '';
|
||
el.innerHTML = `<strong>Advisor (${modeLabel})</strong><br><span style="color:var(--muted)">Trigger: ${data.reason || data.trigger}${routeText}${countText}</span>`;
|
||
if (data.advice?.next_actions?.length) {
|
||
el.innerHTML += '<br><span style="color:#e2e8f0">? ' + data.advice.next_actions[0] + '</span>';
|
||
}
|
||
if (data.preflight?.quick_plan?.length) {
|
||
el.innerHTML += '<br><span style="color:#e2e8f0">? ' + data.preflight.quick_plan[0] + '</span>';
|
||
}
|
||
panel.appendChild(el);
|
||
panel.scrollTop = panel.scrollHeight;
|
||
}
|
||
|
||
// --- Expose to SSE handler ---------------------------------------------------
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window._orchEventHandler = handleOrchestrationEvent;
|
||
}
|
||
|
||
// ---- Refresh all ----
|
||
async function refreshAll() {
|
||
renderSessionsList();
|
||
updateStats([]);
|
||
loadApprovals();
|
||
checkStatus();
|
||
loadSystemStats();
|
||
loadInstalledSkills();
|
||
loadSearchSettingsSummary();
|
||
loadAgentSessionContext();
|
||
}
|
||
|
||
// ---- Log ----
|
||
const logLines = [];
|
||
function log(text, type = 'log') {
|
||
const ts = new Date().toLocaleTimeString();
|
||
logLines.push({ text: `[${ts}] ${text}`, type });
|
||
if (logLines.length > 100) logLines.shift();
|
||
const lp = document.getElementById('log-panel');
|
||
if (lp) {
|
||
lp.innerHTML = logLines.map(l => `<div class="log-line ${l.type}">${l.text}</div>`).join('');
|
||
lp.scrollTop = lp.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// ---- Helpers ----
|
||
function escHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g,'&').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 '<div class="pptx-file-row">'
|
||
+ '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
|
||
+ '<span class="download-icon">⬇</span> ' + text + '</a>'
|
||
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="미리보기">◀ 미리보기</button>'
|
||
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
|
||
+ '</div>';
|
||
}
|
||
if (isPdf) {
|
||
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
|
||
return '<div class="pdf-file-row" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
|
||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||
+ '<a href="' + url + '" target="_blank" rel="noopener" class="download-btn" title="새 탭에서 열기">'
|
||
+ '<span style="font-size:14px">📄</span> ' + text + '</a>'
|
||
+ '<button class="pptx-preview-btn" onclick="togglePdfEmbed(\'' + escHtml(url) + '\',\'' + pdfId + '\',this)">◀ 미리보기</button>'
|
||
+ (hideDownload ? '' : '<a href="' + url + '" onclick="downloadFile(event,this)" class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</a>')
|
||
+ '</div>'
|
||
+ '<div id="' + pdfId + '" style="display:none">'
|
||
+ '<iframe style="width:100%;height:600px;border:1px solid var(--line);border-radius:8px" allowfullscreen></iframe>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}
|
||
if (isXlsx) {
|
||
var xlsxSafeUrl = escHtml(url);
|
||
return '<div class="pdf-file-row xlsx-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
|
||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||
+ '<span style="font-size:14px">📊</span>'
|
||
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
|
||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" data-xlsx-save-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">✏️ 편집</button>'
|
||
+ '<button class="pptx-preview-btn" onclick="openXlsxNewWindow(\'' + xlsxSafeUrl + '\',\'' + escHtml(text) + '\')" style="background:linear-gradient(135deg,#6366f1,#4f46e5)" title="새 창에서 열기">↗ 새 창</button>'
|
||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 다운로드</a>'
|
||
+ '</div>'
|
||
+ ''
|
||
+ '</div>';
|
||
}
|
||
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
|
||
if (isTxt) {
|
||
var txtLabel = escHtml(text.replace(/^📝\s*/, ''));
|
||
return '<div class="pdf-file-row txt-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
|
||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||
+ '<span style="font-size:14px">📝</span>'
|
||
+ '<span style="font-weight:600;color:var(--text)">' + txtLabel + '</span>'
|
||
+ '<button class="pptx-preview-btn" data-txt-url="' + escHtml(url) + '" onclick="toggleTxtEmbedBtn(this)">◀ 미리보기</button>'
|
||
+ '<a href="' + escHtml(url) + '" download="' + txtLabel + '" class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</a>'
|
||
+ '</div>'
|
||
+ '<div class="txt-preview-body" style="display:none;max-height:420px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;word-break:break-word;line-height:1.7;color:var(--text);font-family:inherit"></div>'
|
||
+ '</div>';
|
||
}
|
||
if (isDownload) {
|
||
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
|
||
+ '<span class="download-icon">⬇</span> ' + text + '</a>';
|
||
}
|
||
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
|
||
}
|
||
|
||
function togglePdfEmbed(url, containerId, btn) {
|
||
var container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
var iframe = container.querySelector('iframe');
|
||
if (container.style.display === 'none') {
|
||
container.style.display = 'block';
|
||
if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정
|
||
btn.innerHTML = '▲ 닫기';
|
||
} 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 = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#666;font-size:13px">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>';
|
||
initLuckyModalInteraction();
|
||
|
||
fetch(url, { headers: authHeaders() })
|
||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||
.then(function(buf) {
|
||
loadLuckysheet(function() {
|
||
if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') {
|
||
container.innerHTML = '<div style="padding:20px;color:red">Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.</div>'; return;
|
||
}
|
||
var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
|
||
container.innerHTML = '';
|
||
try { luckysheet.destroy(); } catch(e) {}
|
||
luckysheet.create({
|
||
container: 'lucky-container',
|
||
data: exportInfo.sheets,
|
||
title: '',
|
||
lang: 'en',
|
||
showinfobar: false,
|
||
showstatisticBar: false,
|
||
sheetBottomConfig: true,
|
||
allowUpdate: true,
|
||
enableAddRow: true,
|
||
enableAddCol: true,
|
||
showToolbar: true,
|
||
plugins: [],
|
||
defaultFontSize: 11,
|
||
defaultFontName: 'Nanum Gothic',
|
||
});
|
||
}, function(err) {
|
||
container.innerHTML = '<div style="padding:20px;color:red">Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '</div>';
|
||
});
|
||
});
|
||
})
|
||
.catch(function(e) {
|
||
container.innerHTML = '<div style="padding:20px;color:red">불러오기 실패: ' + escHtml(e.message) + '</div>';
|
||
});
|
||
}
|
||
|
||
function minimizeLuckyModal() {
|
||
var box = document.getElementById('lucky-modal-box');
|
||
var modal = document.getElementById('lucky-modal');
|
||
var btn = event.target;
|
||
if (!box || !modal) return;
|
||
var minimized = box.dataset.minimized === '1';
|
||
if (minimized) {
|
||
box.style.height = box._savedHeight || '92vh';
|
||
box.dataset.minimized = '0';
|
||
btn.textContent = '–';
|
||
btn.title = '최소화';
|
||
if (!box._isAbsolute) { modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
|
||
} else {
|
||
box._savedHeight = box.style.height || '92vh';
|
||
box.dataset.minimized = '1';
|
||
btn.innerHTML = '◻';
|
||
btn.title = '복원';
|
||
if (box._isAbsolute) {
|
||
box.style.top = (window.innerHeight - 52) + 'px';
|
||
box.style.height = 'auto';
|
||
} else {
|
||
box.style.height = 'auto';
|
||
modal.style.alignItems = 'flex-end';
|
||
modal.style.justifyContent = 'center';
|
||
modal.style.paddingBottom = '0';
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeLuckyModal() {
|
||
var modal = document.getElementById('lucky-modal');
|
||
var box = document.getElementById('lucky-modal-box');
|
||
if (modal) { modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
|
||
if (box) {
|
||
box.style.position = 'relative';
|
||
box.style.left = ''; box.style.top = '';
|
||
box.style.width = '96vw'; box.style.height = '92vh';
|
||
box._isAbsolute = false; box._savedHeight = null;
|
||
box.dataset.minimized = '0';
|
||
}
|
||
try { luckysheet.destroy(); } catch(e) {}
|
||
_luckyCurrentSaveUrl = null;
|
||
}
|
||
|
||
// Close on backdrop click
|
||
document.getElementById('lucky-modal')?.addEventListener('click', function(e) {
|
||
if (e.target === this) closeLuckyModal();
|
||
});
|
||
|
||
function initLuckyModalInteraction() {
|
||
var box = document.getElementById('lucky-modal-box');
|
||
var modal = document.getElementById('lucky-modal');
|
||
if (!box || box._interactInit) return;
|
||
box._interactInit = true;
|
||
|
||
// Inject 8 resize handles
|
||
[
|
||
{ dir:'n', css:'top:0;left:10px;right:10px;height:6px;cursor:n-resize' },
|
||
{ dir:'s', css:'bottom:0;left:10px;right:10px;height:6px;cursor:s-resize' },
|
||
{ dir:'e', css:'right:0;top:10px;bottom:10px;width:6px;cursor:e-resize' },
|
||
{ dir:'w', css:'left:0;top:10px;bottom:10px;width:6px;cursor:w-resize' },
|
||
{ dir:'ne', css:'top:0;right:0;width:12px;height:12px;cursor:ne-resize' },
|
||
{ dir:'nw', css:'top:0;left:0;width:12px;height:12px;cursor:nw-resize' },
|
||
{ dir:'se', css:'bottom:0;right:0;width:12px;height:12px;cursor:se-resize' },
|
||
{ dir:'sw', css:'bottom:0;left:0;width:12px;height:12px;cursor:sw-resize' },
|
||
].forEach(function(d) {
|
||
var h = document.createElement('div');
|
||
h.className = 'lucky-rh';
|
||
h.dataset.dir = d.dir;
|
||
h.style.cssText = 'position:absolute;z-index:200;' + d.css;
|
||
box.appendChild(h);
|
||
});
|
||
|
||
function toAbsolute() {
|
||
if (box._isAbsolute) return;
|
||
var rect = box.getBoundingClientRect();
|
||
modal.style.display = 'block';
|
||
box.style.position = 'absolute';
|
||
box.style.left = rect.left + 'px';
|
||
box.style.top = rect.top + 'px';
|
||
box.style.width = rect.width + 'px';
|
||
box.style.height = rect.height + 'px';
|
||
box._isAbsolute = true;
|
||
}
|
||
|
||
// Title bar drag
|
||
var header = box.firstElementChild;
|
||
if (header) {
|
||
header.style.cursor = 'grab';
|
||
header.addEventListener('mousedown', function(e) {
|
||
if (e.target.tagName === 'BUTTON') return;
|
||
toAbsolute();
|
||
var rect = box.getBoundingClientRect();
|
||
var ox = e.clientX - rect.left, oy = e.clientY - rect.top;
|
||
header.style.cursor = 'grabbing';
|
||
function onMove(e) {
|
||
box.style.left = (e.clientX - ox) + 'px';
|
||
box.style.top = Math.max(0, e.clientY - oy) + 'px';
|
||
}
|
||
function onUp() {
|
||
header.style.cursor = 'grab';
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
e.preventDefault();
|
||
});
|
||
}
|
||
|
||
// Resize handle drag
|
||
box.addEventListener('mousedown', function(e) {
|
||
var handle = e.target.closest ? e.target.closest('.lucky-rh') : null;
|
||
if (!handle) return;
|
||
toAbsolute();
|
||
var dir = handle.dataset.dir;
|
||
var rect = box.getBoundingClientRect();
|
||
var sx = e.clientX, sy = e.clientY;
|
||
var sl = rect.left, st = rect.top, sw = rect.width, sh = rect.height;
|
||
document.body.style.userSelect = 'none';
|
||
document.body.style.cursor = handle.style.cursor;
|
||
function onMove(e) {
|
||
var dx = e.clientX - sx, dy = e.clientY - sy;
|
||
var nl = sl, nt = st, nw = sw, nh = sh;
|
||
if (dir.includes('e')) nw = Math.max(380, sw + dx);
|
||
if (dir.includes('s')) nh = Math.max(180, sh + dy);
|
||
if (dir.includes('w')) { nw = Math.max(380, sw - dx); nl = sl + sw - nw; }
|
||
if (dir.includes('n')) { nh = Math.max(180, sh - dy); nt = st + sh - nh; }
|
||
box.style.left = nl + 'px'; box.style.top = nt + 'px';
|
||
box.style.width = nw + 'px'; box.style.height = nh + 'px';
|
||
}
|
||
function onUp() {
|
||
document.body.style.userSelect = '';
|
||
document.body.style.cursor = '';
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
});
|
||
}
|
||
|
||
// ── Voice: STT (Web Speech API — Chrome 내장 Google STT) ────────────────
|
||
var _speechRecog = null;
|
||
var _isRecording = false;
|
||
var _micAutosend = localStorage.getItem('micAutosend') !== 'false'; // default on
|
||
|
||
function updateMicAutosend() {
|
||
_micAutosend = document.getElementById('mic-autosend-toggle').checked;
|
||
localStorage.setItem('micAutosend', String(_micAutosend));
|
||
}
|
||
|
||
function initMicAutosendToggle() {
|
||
var toggle = document.getElementById('mic-autosend-toggle');
|
||
if (toggle) toggle.checked = _micAutosend;
|
||
}
|
||
|
||
function toggleMicRecording() {
|
||
if (_isRecording) stopMicRecording();
|
||
else startMicRecording();
|
||
}
|
||
|
||
function startMicRecording() {
|
||
var SpeechRecog = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (!SpeechRecog) {
|
||
alert('이 브라우저는 음성 인식을 지원하지 않습니다.\nChrome을 사용해주세요.');
|
||
return;
|
||
}
|
||
_speechRecog = new SpeechRecog();
|
||
_speechRecog.lang = 'ko-KR';
|
||
_speechRecog.interimResults = false;
|
||
_speechRecog.maxAlternatives = 1;
|
||
_speechRecog.continuous = false;
|
||
|
||
var btn = document.getElementById('mic-btn');
|
||
|
||
_speechRecog.onstart = function() {
|
||
_isRecording = true;
|
||
if (btn) { btn.textContent = '⏹'; btn.title = '말하는 중… (클릭 시 중지)'; btn.classList.add('recording'); }
|
||
};
|
||
_speechRecog.onresult = function(e) {
|
||
var transcript = e.results[0][0].transcript;
|
||
var input = document.getElementById('chat-input');
|
||
if (input && transcript) {
|
||
input.value = (input.value ? input.value + ' ' : '') + transcript;
|
||
input.focus();
|
||
input.dispatchEvent(new Event('input'));
|
||
if (_micAutosend) setTimeout(function() { handleSendStop(); }, 50);
|
||
}
|
||
};
|
||
_speechRecog.onerror = function(e) {
|
||
if (e.error !== 'no-speech') alert('음성 인식 오류: ' + e.error);
|
||
};
|
||
_speechRecog.onend = function() {
|
||
_isRecording = false;
|
||
_speechRecog = null;
|
||
if (btn) { btn.textContent = '🎤'; btn.title = '음성 입력'; btn.classList.remove('recording'); }
|
||
};
|
||
_speechRecog.start();
|
||
}
|
||
|
||
function stopMicRecording() {
|
||
if (_speechRecog) { _speechRecog.stop(); }
|
||
}
|
||
|
||
// ── Voice: TTS (음성 출력) ───────────────────────────────────────────────
|
||
var _ttsEnabled = localStorage.getItem('ttsEnabled') !== 'false'; // default on
|
||
var _ttsAudio = null;
|
||
var _ttsBtnActive = null;
|
||
|
||
function updateTTSMode() {
|
||
_ttsEnabled = document.getElementById('tts-mode-toggle').checked;
|
||
localStorage.setItem('ttsEnabled', String(_ttsEnabled));
|
||
document.body.classList.toggle('tts-disabled', !_ttsEnabled);
|
||
if (!_ttsEnabled && _ttsAudio) {
|
||
_ttsAudio.pause(); _ttsAudio = null;
|
||
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); _ttsBtnActive = null; }
|
||
}
|
||
}
|
||
|
||
function initTTSToggle() {
|
||
var toggle = document.getElementById('tts-mode-toggle');
|
||
if (toggle) toggle.checked = _ttsEnabled;
|
||
document.body.classList.toggle('tts-disabled', !_ttsEnabled);
|
||
}
|
||
|
||
function playTTSMsg(btn, idx) {
|
||
var msg = chatHistory[idx];
|
||
if (!msg) return;
|
||
var text = (msg.content || '').replace(/!\[.*?\]\(.*?\)/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/```[\s\S]*?```/g, '').replace(/[#*`_~>]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
if (!text) return;
|
||
// Toggle off if same button is playing
|
||
if (_ttsBtnActive === btn && _ttsAudio) {
|
||
_ttsAudio.pause(); _ttsAudio = null;
|
||
btn.textContent = '🔊'; btn.classList.remove('playing');
|
||
_ttsBtnActive = null;
|
||
return;
|
||
}
|
||
// Stop previous
|
||
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
|
||
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); }
|
||
btn.textContent = '⌛'; btn.classList.add('playing');
|
||
_ttsBtnActive = btn;
|
||
fetch('/api/voice/tts', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||
credentials: 'include',
|
||
body: JSON.stringify({ text: text.slice(0, 2000) }),
|
||
})
|
||
.then(function(r) { if (!r.ok) throw new Error('TTS 오류 ' + r.status); return r.blob(); })
|
||
.then(function(blob) {
|
||
var url = URL.createObjectURL(blob);
|
||
_ttsAudio = new Audio(url);
|
||
_ttsAudio.onended = function() {
|
||
URL.revokeObjectURL(url); _ttsAudio = null;
|
||
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
|
||
};
|
||
_ttsAudio.onerror = function() {
|
||
URL.revokeObjectURL(url); _ttsAudio = null;
|
||
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
|
||
};
|
||
btn.textContent = '⏹';
|
||
_ttsAudio.play();
|
||
})
|
||
.catch(function(e) {
|
||
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
|
||
alert('TTS 오류: ' + e.message);
|
||
});
|
||
}
|
||
|
||
function saveLuckyModal() {
|
||
var url = _luckyCurrentSaveUrl;
|
||
if (!url) { alert('저장할 파일 경로가 없습니다.'); return; }
|
||
var btn = document.getElementById('lucky-modal-save');
|
||
var relPath = url.replace(/^\/api\/files\//, '');
|
||
loadSheetJS(function() {
|
||
try {
|
||
// Luckysheet → SheetJS aoa conversion
|
||
var sheets = luckysheet.getAllSheets();
|
||
var wb = XLSX.utils.book_new();
|
||
sheets.forEach(function(sheet) {
|
||
var celldata = sheet.celldata || [];
|
||
if (!celldata.length) { XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([[]]), sheet.name || 'Sheet'); return; }
|
||
var maxR = 0, maxC = 0;
|
||
celldata.forEach(function(c) { maxR = Math.max(maxR, c.r); maxC = Math.max(maxC, c.c); });
|
||
var aoa = [];
|
||
for (var r = 0; r <= maxR; r++) aoa.push(new Array(maxC + 1).fill(''));
|
||
celldata.forEach(function(c) {
|
||
if (c.v == null) return;
|
||
var val = (typeof c.v === 'object') ? (c.v.m !== undefined ? c.v.m : (c.v.v !== undefined ? c.v.v : '')) : c.v;
|
||
aoa[c.r][c.c] = val;
|
||
});
|
||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet');
|
||
});
|
||
var xlsxBuf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||
btn.disabled = true; btn.textContent = '저장 중…';
|
||
fetch('/api/files/' + relPath, {
|
||
method: 'PUT', headers: authHeaders(),
|
||
body: new Blob([xlsxBuf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(res) {
|
||
btn.disabled = false; btn.innerHTML = '💾 저장';
|
||
if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '💾 저장'; }, 2000); }
|
||
else alert('저장 실패: ' + (res.error || '알 수 없는 오류'));
|
||
})
|
||
.catch(function(e) { btn.disabled = false; btn.innerHTML = '💾 저장'; alert('저장 오류: ' + e.message); });
|
||
} catch(e) { alert('Excel 변환 오류: ' + e.message); }
|
||
});
|
||
}
|
||
|
||
function toggleStagedExcelPreview(id) {
|
||
var el = document.getElementById('excel-staged-' + id);
|
||
if (!el) return;
|
||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
function toggleTxtEmbedBtn(btn) {
|
||
var card = btn.closest('.txt-card');
|
||
if (!card) return;
|
||
var container = card.querySelector('.txt-preview-body');
|
||
if (!container) return;
|
||
var url = encodeURI(btn.getAttribute('data-txt-url'));
|
||
if (container.style.display === 'none') {
|
||
container.style.display = 'block';
|
||
btn.innerHTML = '▲ 접기';
|
||
if (!container.dataset.loaded) {
|
||
container.textContent = '불러오는 중…';
|
||
fetch(url, { headers: authHeaders() })
|
||
.then(function(r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.text();
|
||
})
|
||
.then(function(t) {
|
||
container.textContent = t;
|
||
container.dataset.loaded = '1';
|
||
})
|
||
.catch(function(e) {
|
||
container.textContent = '텍스트를 불러올 수 없습니다. (' + e.message + ')';
|
||
});
|
||
}
|
||
} else {
|
||
container.style.display = 'none';
|
||
btn.innerHTML = '◀ 미리보기';
|
||
}
|
||
}
|
||
|
||
function windyToEmbedUrl(url) {
|
||
try {
|
||
var u = new URL(url);
|
||
// Already an embed URL — use as-is
|
||
if (u.hostname === 'embed.windy.com') return url;
|
||
// windy.com map URL → convert to embed
|
||
if (/windy\.com/.test(u.hostname)) {
|
||
var lat = u.searchParams.get('lat') || '';
|
||
var lon = u.searchParams.get('lon') || '';
|
||
var zoom = u.searchParams.get('zoom') || '';
|
||
var overlay = u.searchParams.get('overlay') || '';
|
||
var level = u.searchParams.get('level') || 'surface';
|
||
// Handle windy.com comma format: ?overlay,lat,lon,zoom or pathname /-overlay?overlay,lat,lon,zoom
|
||
if (!lat || !lon) {
|
||
var qs = u.search.slice(1); // e.g. 'radar,36.545,127.974,7'
|
||
var parts = qs.split(',');
|
||
if (parts.length >= 3) {
|
||
overlay = overlay || parts[0];
|
||
lat = parts[1];
|
||
lon = parts[2];
|
||
zoom = zoom || parts[3] || '7';
|
||
}
|
||
}
|
||
lat = lat || '37.5';
|
||
lon = lon || '126.9';
|
||
zoom = zoom || '7';
|
||
overlay = overlay || 'rain';
|
||
// Map radar overlay name
|
||
if (overlay === '-radar' || overlay === 'radar') overlay = 'radar';
|
||
return 'https://embed.windy.com/embed2.html?lat=' + lat + '&lon=' + lon +
|
||
'&zoom=' + zoom + '&level=' + level + '&overlay=' + overlay +
|
||
'&menu=&message=true&marker=&calendar=now&pressure=&type=map' +
|
||
'&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C';
|
||
}
|
||
return null;
|
||
} catch(e) { return null; }
|
||
}
|
||
|
||
function youtubeToEmbedUrl(url) {
|
||
try {
|
||
var u = new URL(url);
|
||
var id = null;
|
||
if (u.hostname === 'youtu.be') {
|
||
id = u.pathname.slice(1).split(/[/?]/)[0];
|
||
} else if (/youtube\.com/.test(u.hostname)) {
|
||
id = u.searchParams.get('v');
|
||
if (!id) {
|
||
var m = u.pathname.match(/\/(?:shorts|embed)\/([^/?]+)/);
|
||
if (m) id = m[1];
|
||
}
|
||
}
|
||
if (!id) return null;
|
||
var start = u.searchParams.get('t') || u.searchParams.get('start');
|
||
return 'https://www.youtube.com/embed/' + id + (start ? '?start=' + parseInt(start, 10) : '');
|
||
} catch(e) { return null; }
|
||
}
|
||
|
||
function mapsToEmbedUrl(url) {
|
||
try {
|
||
var u = new URL(url);
|
||
var q = null;
|
||
var placeMatch = u.pathname.match(/\/maps\/place\/([^/@?]+)/);
|
||
if (placeMatch) q = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||
if (!q) {
|
||
var searchMatch = u.pathname.match(/\/maps\/search\/([^/@?]+)/);
|
||
if (searchMatch) q = decodeURIComponent(searchMatch[1].replace(/\+/g, ' '));
|
||
}
|
||
if (!q) {
|
||
var coordMatch = u.pathname.match(/\/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||
if (coordMatch) q = coordMatch[1] + ',' + coordMatch[2];
|
||
}
|
||
if (!q) q = u.searchParams.get('q') || u.searchParams.get('query');
|
||
if (!q) return null;
|
||
return 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&output=embed&hl=ko';
|
||
} catch(e) { return null; }
|
||
}
|
||
|
||
function renderMarkdown(text, opts) {
|
||
if (!text) return '';
|
||
|
||
// Extract existing <img> tags before escaping so they survive escHtml()
|
||
var imgPlaceholders = [];
|
||
var imgRe = /<img\s+[^>]*class=["']md-img["'][^>]*>/gi;
|
||
var textSafe = text.replace(imgRe, function(m) {
|
||
var idx = imgPlaceholders.length;
|
||
imgPlaceholders.push(m);
|
||
return '\x00IMG' + idx + '\x00';
|
||
});
|
||
|
||
var html = escHtml(textSafe);
|
||
|
||
// Restore <img> tags (already safe HTML)
|
||
imgPlaceholders.forEach(function(img, i) {
|
||
html = html.replace('\x00IMG' + i + '\x00', img);
|
||
});
|
||
|
||
// Code blocks (``` ... ```)
|
||
html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) {
|
||
var trimmed = code.trim();
|
||
if (lang === 'midi-widget') {
|
||
try {
|
||
var mwRaw = trimmed.replace(/&/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 '<pre class="md-code-block"><code>' + escHtml(trimmed) + '</code></pre>';
|
||
}
|
||
}
|
||
if (lang === 'abc' || lang === 'lilypond') {
|
||
var encoded = encodeURIComponent(trimmed);
|
||
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
|
||
return '<div class="music-widget" id="' + widgetId + '">' +
|
||
'<div class="music-widget-header">' +
|
||
'<span class="music-widget-label">🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '</span>' +
|
||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||
'<button class="music-render-btn" onclick="renderMusicBlock(\'' + widgetId + '\', decodeURIComponent(\'' + encoded + '\'))">악보 렌더링</button>' +
|
||
'<button onclick="toggleMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
|
||
'<button onclick="closeMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="music-widget-body">' +
|
||
'<pre class="md-code-block music-source" style="margin:0;border-radius:0;border:none;border-bottom:1px solid var(--line);max-height:120px;overflow-y:auto;font-size:11px"><code>' + escHtml(trimmed) + '</code></pre>' +
|
||
'<div class="music-output" style="display:none"></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
if (lang === 'tradingview') {
|
||
var tvSymbol = '', tvInterval = 'D';
|
||
// code content is already HTML-escaped at this point — unescape before parsing
|
||
var tvRaw = trimmed.replace(/&/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 '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
|
||
var tvId = 'tv-' + Math.random().toString(36).slice(2, 9);
|
||
return '<div class="music-widget" id="' + tvId + '">' +
|
||
'<div class="music-widget-header">' +
|
||
'<span class="music-widget-label">📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '</span>' +
|
||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||
'<button class="music-render-btn" onclick="renderTVChart(this,\'' + tvId + '-c\',\'' + escHtml(tvSymbol) + '\',\'' + escHtml(tvInterval) + '\')">📈 차트 불러오기</button>' +
|
||
'<button onclick="toggleMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
|
||
'<button onclick="closeMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="music-widget-body">' +
|
||
'<div id="' + tvId + '-c" style="height:440px;width:100%"></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
if (lang === 'kstock') {
|
||
var ksRaw = trimmed.replace(/&/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 '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
|
||
var ksId = 'ks-' + Math.random().toString(36).slice(2, 9);
|
||
var ksImgUrl = 'https://ssl.pstatic.net/imgfinance/chart/item/area/' + ksNaverPeriod + '/' + ksCode + '.png';
|
||
var ksNaverUrl = 'https://finance.naver.com/item/main.naver?code=' + ksCode;
|
||
return '<div class="music-widget" id="' + ksId + '">' +
|
||
'<div class="music-widget-header">' +
|
||
'<span class="music-widget-label">📈 ' + escHtml(ksCode) + ' · ' + escHtml(ksPeriodLabel) + ' · 네이버금융</span>' +
|
||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||
'<a href="' + ksNaverUrl + '" target="_blank" style="font-size:12px;color:var(--muted);text-decoration:none">네이버금융 ↗</a>' +
|
||
'<button onclick="toggleMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
|
||
'<button onclick="closeMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="music-widget-body" style="padding:8px;text-align:center">' +
|
||
'<img src="' + ksImgUrl + '" alt="' + escHtml(ksCode) + ' 차트" style="max-width:100%;border-radius:4px" onerror="this.parentNode.innerHTML=\'<span style=\\\"color:var(--muted);font-size:13px\\\">차트를 불러올 수 없습니다.</span>\'">' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
|
||
});
|
||
|
||
// Inline code
|
||
html = html.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
|
||
|
||
// Images: 
|
||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
|
||
var safeAlt = alt.replace(/"/g, '"');
|
||
var errAlt = (alt || '이미지').replace(/'/g, ''').replace(/</g, '<');
|
||
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class="md-img-err">✖ " + errAlt + "를 불러올 수 없습니다.</span>')";
|
||
return '<img class="md-img" src="' + url + '" alt="' + safeAlt + '" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />';
|
||
});
|
||
|
||
// Links: [text](url) — render download links for file types as buttons, PPTX gets preview
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) {
|
||
return _renderLinkButton(text, url, opts);
|
||
});
|
||
|
||
// Bare /api/files/... URLs — auto-convert to download buttons even without markdown syntax
|
||
html = html.replace(/(\s|^)(\/api\/files\/[^\s<"]+)/g, function(_, space, url) {
|
||
return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url, opts);
|
||
});
|
||
|
||
// YouTube URLs on their own line → inline iframe
|
||
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:(?:www\.|m\.)?youtube\.com\/(?:watch|shorts|embed)|youtu\.be\/)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
|
||
var embedUrl = youtubeToEmbedUrl(url);
|
||
if (!embedUrl) return m;
|
||
var safeEmbed = embedUrl.replace(/"/g, '"');
|
||
var safeOrig = url.replace(/"/g, '"');
|
||
return pre + '<div class="yt-embed-wrap"><iframe class="yt-embed" src="' + safeEmbed + '" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">YouTube에서 열기 ↗</a></div></div>' + post;
|
||
});
|
||
|
||
// Google Maps URLs on their own line → inline iframe
|
||
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:www\.google\.com\/maps|maps\.google\.com|goo\.gl\/maps)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
|
||
var embedUrl = mapsToEmbedUrl(url);
|
||
if (!embedUrl) return m;
|
||
var safeEmbed = embedUrl.replace(/"/g, '"');
|
||
var safeOrig = url.replace(/"/g, '"');
|
||
return pre + '<div class="map-embed-wrap"><iframe class="map-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Google 지도에서 열기 ↗</a></div></div>' + post;
|
||
});
|
||
|
||
// Windy URLs in <img src="..."> (model used  syntax) → inline iframe [must run first]
|
||
html = html.replace(/<img\s[^>]*src="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*\/?>/gi, function(m, url) {
|
||
var embedUrl = windyToEmbedUrl(url);
|
||
if (!embedUrl) return m;
|
||
var safeEmbed = embedUrl.replace(/"/g, '"');
|
||
var safeOrig = url.replace(/"/g, '"');
|
||
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
|
||
});
|
||
|
||
// Windy URLs inside anchor tags (markdown links) → inline iframe [must run BEFORE bare-URL regex]
|
||
html = html.replace(/<a\s[^>]*href="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*>[^<]*<\/a>/gi, function(m, url) {
|
||
var embedUrl = windyToEmbedUrl(url);
|
||
if (!embedUrl) return m;
|
||
var safeEmbed = embedUrl.replace(/"/g, '"');
|
||
var safeOrig = url.replace(/"/g, '"');
|
||
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
|
||
});
|
||
|
||
// Windy URLs on their own line → inline iframe [runs after anchor regex to avoid double-render]
|
||
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
|
||
var embedUrl = windyToEmbedUrl(url);
|
||
if (!embedUrl) return m;
|
||
var safeEmbed = embedUrl.replace(/"/g, '"');
|
||
var safeOrig = url.replace(/"/g, '"');
|
||
return pre + '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>' + post;
|
||
});
|
||
|
||
// Bare image URLs on their own line → inline image
|
||
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/[^\s<>"()\n#?]+\.(?:jpe?g|png|gif|webp|svg|bmp|avif|tiff)(?:[?#][^\s<>"()\n]*)?)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
|
||
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class="md-img-err">✖ 이미지를 불러올 수 없습니다.</span>')";
|
||
return pre + '<img class="md-img" src="' + url + '" alt="" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />' + post;
|
||
});
|
||
|
||
// Bold
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
|
||
// Italic
|
||
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||
|
||
// Markdown tables → HTML (line-by-line, O(n), must run BEFORE \n → <br>)
|
||
(function() {
|
||
var lines = html.split('\n');
|
||
var out = [];
|
||
var parseRow = function(line) {
|
||
var cells = line.trim().split('|');
|
||
if (cells.length && cells[0].trim() === '') cells.shift();
|
||
if (cells.length && cells[cells.length - 1].trim() === '') cells.pop();
|
||
return cells.map(function(c) { return c.trim(); });
|
||
};
|
||
var isSep = function(line) { return /^\|[\s|:\-]+\|[ \t]*$/.test(line.trim()); };
|
||
var isRow = function(line) { return /^\|.+\|[ \t]*$/.test(line.trim()); };
|
||
var i = 0;
|
||
while (i < lines.length) {
|
||
var line = lines[i];
|
||
if (isRow(line) && i + 1 < lines.length && isSep(lines[i + 1])) {
|
||
var tbl = [line, lines[i + 1]];
|
||
var j = i + 2;
|
||
while (j < lines.length && isRow(lines[j])) { tbl.push(lines[j]); j++; }
|
||
var headers = parseRow(tbl[0]);
|
||
var thead = '<thead><tr>' + headers.map(function(h) { return '<th>' + h + '</th>'; }).join('') + '</tr></thead>';
|
||
var tbody = '<tbody>' + tbl.slice(2).map(function(row) {
|
||
return '<tr>' + parseRow(row).map(function(c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
|
||
}).join('') + '</tbody>';
|
||
out.push('<div class="md-table-wrap"><table class="md-table">' + thead + tbody + '</table></div>');
|
||
i = j;
|
||
continue;
|
||
}
|
||
out.push(line);
|
||
i++;
|
||
}
|
||
html = out.join('\n');
|
||
})();
|
||
|
||
// Line breaks
|
||
html = html.replace(/\n/g, '<br>');
|
||
|
||
return html;
|
||
}
|
||
|
||
function renderTVChart(btn, containerId, symbol, interval) {
|
||
btn.disabled = true;
|
||
btn.textContent = '로딩 중...';
|
||
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||
function doInit() {
|
||
try {
|
||
new TradingView.widget({
|
||
container_id: containerId,
|
||
autosize: true,
|
||
symbol: symbol,
|
||
interval: interval || 'D',
|
||
timezone: 'Asia/Seoul',
|
||
theme: isDark ? 'dark' : 'light',
|
||
style: '1',
|
||
locale: 'kr',
|
||
enable_publishing: false,
|
||
allow_symbol_change: true,
|
||
save_image: false,
|
||
withdateranges: true,
|
||
hide_side_toolbar: false,
|
||
studies: ['RSI@tv-basicstudies', 'MACD@tv-basicstudies'],
|
||
});
|
||
btn.style.display = 'none';
|
||
} catch(e) {
|
||
btn.disabled = false;
|
||
btn.textContent = '📈 차트 불러오기';
|
||
}
|
||
}
|
||
if (window.TradingView && window.TradingView.widget) {
|
||
doInit();
|
||
} else {
|
||
var s = document.createElement('script');
|
||
s.src = 'https://s3.tradingview.com/tv.js';
|
||
s.onload = doInit;
|
||
s.onerror = function() { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; };
|
||
document.head.appendChild(s);
|
||
}
|
||
}
|
||
|
||
function toggleMusicWidget(widgetId) {
|
||
var widget = document.getElementById(widgetId);
|
||
if (!widget) return;
|
||
// widgetId may point to either .music-widget or .music-widget-body
|
||
var body = widget.classList.contains('music-widget-body')
|
||
? widget
|
||
: widget.querySelector('.music-widget-body');
|
||
var btn = widget.classList.contains('music-widget-body')
|
||
? widget.closest('.music-widget')?.querySelector('[title="접기/펼치기"]')
|
||
: widget.querySelector('[title="접기/펼치기"]');
|
||
if (!body) return;
|
||
var collapsed = body.classList.toggle('collapsed');
|
||
if (btn) btn.textContent = collapsed ? '▸' : '▾';
|
||
}
|
||
|
||
function closeMusicWidget(widgetId) {
|
||
var el = document.getElementById(widgetId);
|
||
if (!el) return;
|
||
var widget = el.closest('.music-widget') || el;
|
||
widget.remove();
|
||
}
|
||
|
||
// ── Web MIDI router ──────────────────────────────────────────────────────────
|
||
const midiRouter = {
|
||
access: null,
|
||
timers: [],
|
||
isPlaying: false,
|
||
|
||
stop() {
|
||
this.timers.forEach(t => clearTimeout(t));
|
||
this.timers = [];
|
||
this.isPlaying = false;
|
||
if (this.access) {
|
||
this.access.outputs.forEach(out => {
|
||
for (let ch = 0; ch < 16; ch++) {
|
||
try { out.send([0xB0 | ch, 123, 0]); } catch {} // All Notes Off
|
||
try { out.send([0xB0 | ch, 121, 0]); } catch {} // Reset All Controllers
|
||
}
|
||
});
|
||
}
|
||
},
|
||
|
||
// Parse raw MIDI bytes → scheduled events list
|
||
// Returns [{timeMs, msg:[...bytes]}, ...]
|
||
_parseMidiRaw(buf) {
|
||
const u8 = new Uint8Array(buf);
|
||
const dv = new DataView(buf);
|
||
if (dv.getUint32(0) !== 0x4D546864) return []; // not MThd
|
||
const tpq = dv.getUint16(12);
|
||
const ntrk = dv.getUint16(10);
|
||
let tempo = 500000;
|
||
const events = [];
|
||
|
||
const readVarLen = (pos) => {
|
||
let val = 0, b;
|
||
do { b = u8[pos++]; val = (val << 7) | (b & 0x7F); } while (b & 0x80);
|
||
return { val, pos };
|
||
};
|
||
const ticksToMs = (ticks) => (ticks / tpq) * (tempo / 1000);
|
||
|
||
let p = 14;
|
||
for (let t = 0; t < ntrk; t++) {
|
||
if (p + 8 > u8.length || dv.getUint32(p) !== 0x4D54726B) break;
|
||
const tlen = dv.getUint32(p + 4);
|
||
p += 8;
|
||
const end = p + tlen;
|
||
let tick = 0, rs = 0;
|
||
while (p < end) {
|
||
const dl = readVarLen(p); p = dl.pos; tick += dl.val;
|
||
let b = u8[p];
|
||
if (b & 0x80) { rs = b; p++; } else { b = rs; }
|
||
const typ = b & 0xF0, ch = b & 0x0F;
|
||
const ms = ticksToMs(tick);
|
||
if (typ === 0x90 || typ === 0x80) {
|
||
const note = u8[p++], vel = u8[p++];
|
||
events.push({ ms, msg: [typ | ch, note, vel] });
|
||
} else if (typ === 0xA0 || typ === 0xB0 || typ === 0xE0) {
|
||
events.push({ ms, msg: [b, u8[p], u8[p+1]] }); p += 2;
|
||
} else if (typ === 0xC0) {
|
||
events.push({ ms, msg: [0xC0 | ch, u8[p++]] });
|
||
} else if (typ === 0xD0) {
|
||
events.push({ ms, msg: [b, u8[p++]] });
|
||
} else if (b === 0xFF) {
|
||
const mt = u8[p++];
|
||
const ll = readVarLen(p); p = ll.pos;
|
||
if (mt === 0x51 && ll.val >= 3) {
|
||
// Tempo change: recalculate from current tick
|
||
tempo = (u8[p] << 16) | (u8[p+1] << 8) | u8[p+2];
|
||
}
|
||
p += ll.val;
|
||
} else if (b === 0xF0 || b === 0xF7) {
|
||
const ll = readVarLen(p); p = ll.pos + ll.val;
|
||
} else { p++; }
|
||
}
|
||
p = end;
|
||
}
|
||
events.sort((a, b) => a.ms - b.ms);
|
||
return events;
|
||
},
|
||
|
||
async play(midiUrl, outputId, onStatus) {
|
||
this.stop();
|
||
const output = this.access.outputs.get(outputId);
|
||
if (!output) throw new Error('기기를 찾을 수 없습니다');
|
||
|
||
if (onStatus) onStatus('로딩 중...');
|
||
const resp = await fetch(midiUrl);
|
||
const buf = await resp.arrayBuffer();
|
||
|
||
// Parse raw MIDI for exact Program Change + all events
|
||
const events = this._parseMidiRaw(buf);
|
||
if (!events.length) throw new Error('MIDI 파싱 실패');
|
||
|
||
// GM Reset SysEx → 신디를 GM 모드로 초기화
|
||
try { output.send(new Uint8Array([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7])); } catch {}
|
||
|
||
// Send all PC + Bank Select (CC#0=0, CC#32=0) events upfront
|
||
const initPc = new Set();
|
||
events.forEach(ev => {
|
||
if ((ev.msg[0] & 0xF0) === 0xC0 && !initPc.has(ev.msg[0] & 0x0F)) {
|
||
initPc.add(ev.msg[0] & 0x0F);
|
||
const ch = ev.msg[0] & 0x0F;
|
||
try { output.send(new Uint8Array([0xB0 | ch, 0, 0])); } catch {} // Bank Select MSB=0
|
||
try { output.send(new Uint8Array([0xB0 | ch, 32, 0])); } catch {} // Bank Select LSB=0
|
||
try { output.send(new Uint8Array(ev.msg)); } catch {}
|
||
}
|
||
});
|
||
|
||
const NOTE_DELAY = 200; // ms — give synth time to process GM reset + PCs
|
||
const totalMs = events[events.length - 1].ms + NOTE_DELAY + 500;
|
||
this.isPlaying = true;
|
||
if (onStatus) onStatus('재생 중...');
|
||
|
||
events.forEach(ev => {
|
||
this.timers.push(setTimeout(() => {
|
||
if (!this.isPlaying) return;
|
||
try { output.send(new Uint8Array(ev.msg)); } catch {}
|
||
}, ev.ms + NOTE_DELAY));
|
||
});
|
||
|
||
this.timers.push(setTimeout(() => {
|
||
this.isPlaying = false;
|
||
if (onStatus) onStatus('완료');
|
||
}, totalMs));
|
||
}
|
||
};
|
||
|
||
async function midiRouterToggle(routerId, btnId) {
|
||
const panel = document.getElementById(routerId);
|
||
if (!panel) { alert('패널을 찾을 수 없습니다: ' + routerId); return; }
|
||
const isHidden = panel.style.display === 'none' || panel.style.display === '';
|
||
if (isHidden) {
|
||
panel.style.display = 'flex';
|
||
panel.style.flexWrap = 'wrap';
|
||
panel.style.gap = '6px';
|
||
panel.style.alignItems = 'center';
|
||
const sel = document.getElementById(routerId + '-sel');
|
||
const st = document.getElementById(routerId + '-st');
|
||
try {
|
||
if (!navigator.requestMIDIAccess) throw new Error('Web MIDI 미지원 — Chrome/Edge 필요');
|
||
if (!midiRouter.access) {
|
||
midiRouter.access = await navigator.requestMIDIAccess();
|
||
}
|
||
if (sel) {
|
||
sel.innerHTML = '<option value="">-- MIDI 기기 선택 --</option>';
|
||
midiRouter.access.outputs.forEach((out, id) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = id; opt.textContent = out.name; sel.appendChild(opt);
|
||
});
|
||
if (sel.options.length <= 1 && st) st.textContent = 'MIDI 출력 기기 없음';
|
||
}
|
||
} catch (e) {
|
||
if (st) st.textContent = '오류: ' + e.message;
|
||
}
|
||
} else {
|
||
panel.style.display = 'none';
|
||
midiRouter.stop();
|
||
}
|
||
}
|
||
|
||
async function midiRouterPlay(routerId, midiUrl) {
|
||
const sel = document.getElementById(routerId + '-sel');
|
||
const st = document.getElementById(routerId + '-st');
|
||
const outputId = sel ? sel.value : '';
|
||
if (!outputId) { if (st) st.textContent = '기기를 선택하세요'; return; }
|
||
try {
|
||
await midiRouter.play(midiUrl, outputId, msg => { if (st) st.textContent = msg; });
|
||
} catch (e) { if (st) st.textContent = '오류: ' + e.message; }
|
||
}
|
||
|
||
function midiRouterStop(routerId) {
|
||
midiRouter.stop();
|
||
const st = document.getElementById(routerId + '-st');
|
||
if (st) st.textContent = '정지';
|
||
}
|
||
|
||
function buildMusicActions(data, downloadName) {
|
||
var midiUrl = data.midi_url;
|
||
var pdfUrl = data.pdf_url;
|
||
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
|
||
var html = '<div class="music-actions">';
|
||
if (data.saved_to) {
|
||
html += '<div style="font-size:11px;color:var(--muted);margin-bottom:6px;width:100%">💾 ' + escHtml(data.saved_to) + '</div>';
|
||
}
|
||
if (midiUrl) {
|
||
html += '<div class="midi-router-panel" id="' + rid + '" style="display:none;margin-bottom:4px">'
|
||
+ '<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>'
|
||
+ '<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>'
|
||
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
|
||
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
|
||
+ '</div>';
|
||
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
|
||
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/music-app.html?edit=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>';
|
||
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
|
||
}
|
||
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function buildSvgPagesHtml(data) {
|
||
var pages = data.page_count || 1;
|
||
var baseId = data.id;
|
||
// Use a random suffix so duplicate renders in chat history never share the same navId
|
||
var navId = 'music-nav-' + Math.random().toString(36).slice(2, 10);
|
||
var src1 = pages === 1 ? data.svg_url : '/api/music/file/' + baseId + '/svg/1';
|
||
|
||
var nav = '';
|
||
if (pages > 1) {
|
||
nav = '<div class="music-page-nav" id="' + navId + '" data-page="1" data-total="' + pages + '" data-id="' + baseId + '">' +
|
||
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',-1)" id="' + navId + '-prev" disabled>◀</button>' +
|
||
'<span id="' + navId + '-label">1 / ' + pages + '</span>' +
|
||
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',1)" id="' + navId + '-next"' + (pages <= 1 ? ' disabled' : '') + '>▶</button>' +
|
||
'</div>';
|
||
}
|
||
return nav + '<img id="' + navId + '-img" src="' + src1 + '" alt="악보" style="width:100%;height:auto;background:#fff;border-radius:8px;padding:8px;display:block" />';
|
||
}
|
||
|
||
function musicPageNav(navId, delta) {
|
||
var nav = document.getElementById(navId);
|
||
if (!nav) return;
|
||
var cur = parseInt(nav.dataset.page, 10);
|
||
var total = parseInt(nav.dataset.total, 10);
|
||
var next = cur + delta;
|
||
if (next < 1 || next > total) return;
|
||
nav.dataset.page = String(next);
|
||
var baseId = nav.dataset.id;
|
||
var img = document.getElementById(navId + '-img');
|
||
var lbl = document.getElementById(navId + '-label');
|
||
var prev = document.getElementById(navId + '-prev');
|
||
var nxt = document.getElementById(navId + '-next');
|
||
var url = '/api/music/file/' + baseId + '/svg/' + next;
|
||
if (img) img.src = url;
|
||
if (lbl) lbl.textContent = next + ' / ' + total;
|
||
if (prev) prev.disabled = next <= 1;
|
||
if (nxt) nxt.disabled = next >= total;
|
||
}
|
||
|
||
async function renderMusicBlock(widgetId, source) {
|
||
var widget = document.getElementById(widgetId);
|
||
if (!widget) return;
|
||
var btn = widget.querySelector('.music-render-btn');
|
||
var output = widget.querySelector('.music-output');
|
||
if (btn) { btn.disabled = true; btn.textContent = '렌더링 중…'; }
|
||
try {
|
||
var data = await api('/api/music/render', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ source: source }),
|
||
});
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
var html = buildSvgPagesHtml(data);
|
||
html += buildMusicActions(data, 'score.midi');
|
||
|
||
if (output) {
|
||
output.innerHTML = html;
|
||
output.style.display = 'block';
|
||
// Update chatHistory so the rendered output survives page reload
|
||
persistActiveChat();
|
||
}
|
||
if (btn) { btn.textContent = '다시 렌더링'; btn.disabled = false; }
|
||
} catch (e) {
|
||
if (output) {
|
||
output.innerHTML = '<span style="color:var(--err);font-size:12px">렌더링 실패: ' + escHtml(e.message) + '</span>';
|
||
output.style.display = 'block';
|
||
}
|
||
if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; }
|
||
}
|
||
}
|
||
|
||
// ─── MIDI 업로드 → 액션 피커 ──────────────────────────────────────────────────
|
||
|
||
function buildMidiPickerWidget(data, filename) {
|
||
var wid = 'midi-' + Math.random().toString(36).slice(2, 10);
|
||
var midiUrl = data.midi_url;
|
||
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
|
||
|
||
var savedLine = data.saved_to
|
||
? '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">💾 ' + escHtml(data.saved_to) + '</div>'
|
||
: '';
|
||
|
||
// MIDI player panel (hidden by default) — includes MIDI download
|
||
var playerPanel =
|
||
'<div id="' + rid + '" class="midi-router-panel" style="display:none;margin-top:8px">' +
|
||
'<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>' +
|
||
'<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>' +
|
||
'<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>' +
|
||
'<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(filename) + '" style="padding:2px 8px;font-size:11px">⬇ MIDI</a>' +
|
||
'<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' +
|
||
'</div>';
|
||
|
||
// Score area — always hidden by default; content pre-filled if already rendered
|
||
var scoreInner = (data.has_svg && data.svg_url)
|
||
? buildSvgPagesHtml(data) + buildMusicActions(data, filename)
|
||
: '';
|
||
var scoreArea = '<div id="' + wid + '-score" style="margin-top:8px;display:none">' + scoreInner + '</div>';
|
||
|
||
var scoreBtnLabel = '🎼 악보';
|
||
|
||
var html =
|
||
'<div class="music-widget" id="' + wid + '">' +
|
||
'<div class="music-widget-header">' +
|
||
'<span class="music-widget-label">🎵 ' + escHtml(filename) + '</span>' +
|
||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
|
||
'<button onclick="closeMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="music-widget-body"><div class="music-output">' +
|
||
savedLine +
|
||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
|
||
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
|
||
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/music-app.html?edit=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
|
||
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
|
||
'</div>' +
|
||
playerPanel +
|
||
scoreArea +
|
||
'</div></div>' +
|
||
'</div>';
|
||
return { wid, html };
|
||
}
|
||
|
||
async function midiPickerScore(wid, midiId, filename) {
|
||
var scoreDiv = document.getElementById(wid + '-score');
|
||
var btn = document.getElementById(wid + '-score-btn');
|
||
if (!scoreDiv) return;
|
||
|
||
// If score already rendered, toggle visibility
|
||
if (scoreDiv.style.display !== 'none' && scoreDiv.innerHTML.trim()) {
|
||
scoreDiv.style.display = 'none';
|
||
if (btn) btn.textContent = '📄 악보';
|
||
return;
|
||
}
|
||
if (scoreDiv.innerHTML.trim()) {
|
||
scoreDiv.style.display = '';
|
||
if (btn) btn.textContent = '📄 닫기';
|
||
return;
|
||
}
|
||
|
||
// Show progress
|
||
scoreDiv.style.display = '';
|
||
scoreDiv.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">📄 악보 변환 중... <span id="' + wid + '-sprog"></span></div>';
|
||
if (btn) btn.disabled = true;
|
||
|
||
try {
|
||
var resp = await fetch('/api/music/midi-score', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||
body: JSON.stringify({ id: midiId }),
|
||
});
|
||
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
|
||
var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = '';
|
||
|
||
while (true) {
|
||
var chunk = await reader.read();
|
||
if (chunk.done) break;
|
||
buf += decoder.decode(chunk.value, { stream: true });
|
||
var parts = buf.split('\n\n'); buf = parts.pop() || '';
|
||
for (var i = 0; i < parts.length; i++) {
|
||
if (!parts[i].startsWith('data: ')) continue;
|
||
var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; }
|
||
if (ev.type === 'progress') {
|
||
var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message;
|
||
} else if (ev.type === 'done') {
|
||
scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename);
|
||
if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; }
|
||
} else if (ev.type === 'error') {
|
||
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">악보 변환 실패: ' + escHtml(ev.message) + '</span>';
|
||
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">오류: ' + escHtml(e.message) + '</span>';
|
||
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
|
||
}
|
||
}
|
||
|
||
async function uploadMidiAndRender(file) {
|
||
var msgId = 'midi-upload-' + Date.now();
|
||
appendChatBubble('system',
|
||
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">🎵 <strong>' + escHtml(file.name) + '</strong> 업로드 중...</div>'
|
||
);
|
||
try {
|
||
var form = new FormData();
|
||
form.append('file', file);
|
||
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
|
||
var data = await resp.json();
|
||
if (!resp.ok) throw new Error(data.error || '서버 오류 ' + resp.status);
|
||
|
||
var result = buildMidiPickerWidget(data, file.name);
|
||
var placeholder = document.getElementById(msgId);
|
||
if (placeholder) placeholder.outerHTML = result.html;
|
||
chatHistory.push({ role: 'widget', content: result.html });
|
||
persistActiveChat();
|
||
} catch (e) {
|
||
var pl = document.getElementById(msgId);
|
||
if (pl) pl.outerHTML = '<span style="color:var(--err);font-size:12px">업로드 실패: ' + escHtml(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
function appendChatBubble(role, html) {
|
||
var wrap = document.getElementById('chat-messages');
|
||
if (!wrap) return;
|
||
var div = document.createElement('div');
|
||
div.className = 'msg msg-' + role;
|
||
div.innerHTML = '<div class="msg-content">' + html + '</div>';
|
||
wrap.appendChild(div);
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
}
|
||
|
||
function openImageZoom(src) {
|
||
var modal = document.getElementById('image-zoom-modal');
|
||
var img = document.getElementById('image-zoom-img');
|
||
if (modal && img) {
|
||
img.src = src;
|
||
modal.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function closeImageZoom() {
|
||
var modal = document.getElementById('image-zoom-modal');
|
||
if (modal) modal.style.display = 'none';
|
||
}
|
||
|
||
// Cache of checked /api/files/ URLs: url -> true (exists) | false (not found)
|
||
var _dlLinkCache = {};
|
||
|
||
function markDownloadLinkError(btn) {
|
||
var href = btn.getAttribute ? btn.getAttribute('href') : (btn.href || '');
|
||
var filename = decodeURIComponent((href || '').split('/').pop() || '파일');
|
||
_dlLinkCache[href] = false;
|
||
var span = document.createElement('span');
|
||
span.className = 'download-btn-error';
|
||
span.title = '파일을 찾을 수 없음: ' + filename;
|
||
span.textContent = '⚠ ' + filename + ' (파일 없음)';
|
||
if (btn.parentNode) btn.parentNode.replaceChild(span, btn);
|
||
}
|
||
|
||
function validateDownloadLinks(container) {
|
||
var buttons = (container || document).querySelectorAll('a.download-btn[href]');
|
||
buttons.forEach(function(btn) {
|
||
var url = btn.getAttribute('href');
|
||
if (!url || url.indexOf('/api/files/') === -1) return;
|
||
if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; }
|
||
if (_dlLinkCache[url] === true || _dlLinkCache[url] === 'pending') return;
|
||
_dlLinkCache[url] = 'pending';
|
||
fetch(url, { method: 'HEAD', headers: authHeaders() })
|
||
.then(function(r) {
|
||
_dlLinkCache[url] = r.ok;
|
||
if (!r.ok) {
|
||
var found = (container || document).querySelector('a.download-btn[href="' + url.replace(/"/g, '\\"') + '"]');
|
||
if (found) markDownloadLinkError(found);
|
||
}
|
||
})
|
||
.catch(function() { _dlLinkCache[url] = false; });
|
||
});
|
||
}
|
||
|
||
function downloadFile(e, el) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var url = el.getAttribute('href');
|
||
if (!url) return;
|
||
|
||
// Ensure absolute URL (handle sandboxed environments where origin may be sandbox:/)
|
||
if (!url.match(/^https?:\/\//)) {
|
||
if (url.charAt(0) === '/') {
|
||
// Already an absolute path — use relative to current host
|
||
url = url;
|
||
} else {
|
||
url = new URL(url, window.location.origin).href;
|
||
}
|
||
}
|
||
|
||
var filename = decodeURIComponent(url.split('/').pop() || 'download');
|
||
|
||
fetch(url, {
|
||
headers: authHeaders()
|
||
})
|
||
.then(function(r) {
|
||
if (!r.ok) throw new Error(r.status);
|
||
return r.blob();
|
||
})
|
||
.then(function(blob) {
|
||
var blobUrl = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = blobUrl;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(blobUrl);
|
||
})
|
||
.catch(function(err) {
|
||
markDownloadLinkError(el);
|
||
});
|
||
}
|
||
|
||
function togglePptxPreview(fileUrl, containerId, btn) {
|
||
var container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
var isVisible = container.style.display !== 'none';
|
||
if (isVisible) {
|
||
container.style.display = 'none';
|
||
btn.innerHTML = '◀ Preview';
|
||
return;
|
||
}
|
||
// Show loading state
|
||
container.style.display = 'flex';
|
||
container.innerHTML = '<span style="color:var(--muted);font-size:13px;padding:12px">Generating preview...</span>';
|
||
btn.innerHTML = '▲ Preview';
|
||
btn.classList.add('loading');
|
||
|
||
// Extract relative path from various URL formats:
|
||
// /api/files/Folder/file.pptx → Folder/file.pptx
|
||
// https://host/api/files/Folder/file.pptx → Folder/file.pptx
|
||
// Folder/file.pptx → Folder/file.pptx
|
||
var filePath = fileUrl;
|
||
// Handle sandboxed environments where origin may be sandbox:/
|
||
if (fileUrl.charAt(0) === '/') {
|
||
filePath = fileUrl; // Already an absolute path, use as-is
|
||
} else {
|
||
try { filePath = new URL(fileUrl, window.location.origin).pathname; } catch(e) {}
|
||
}
|
||
filePath = filePath.replace(/^\/api\/files\/?/, '');
|
||
// If the link itself is a preview URL, extract the path query parameter
|
||
var previewMatch = filePath.match(/^\/api\/pptx\/preview\?path=(.+)$/);
|
||
if (previewMatch) {
|
||
filePath = previewMatch[1];
|
||
}
|
||
// Safely decode - handle already-decoded or invalid encodings
|
||
try { filePath = decodeURIComponent(filePath); } catch(e) {}
|
||
fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), {
|
||
headers: authHeaders()
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
btn.classList.remove('loading');
|
||
if (!data.success || !data.images || !data.images.length) {
|
||
container.innerHTML = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview failed: ' + escHtml(data.error || 'No slides found') + '</span>';
|
||
return;
|
||
}
|
||
// img 태그는 Bearer 헤더를 보낼 수 없어서 auth 없이 요청 → user context 미설정 → 파일 못 찾음.
|
||
// fetch()로 auth 포함해 가져온 뒤 Blob URL로 교체.
|
||
Promise.all(data.images.map(function(imgUrl) {
|
||
return fetch(imgUrl, { headers: authHeaders() })
|
||
.then(function(r) { return r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status)); })
|
||
.then(function(blob) { return URL.createObjectURL(blob); })
|
||
.catch(function() { return imgUrl; });
|
||
})).then(function(srcs) {
|
||
container.innerHTML = srcs.map(function(src, i) {
|
||
return '<img src="' + src + '" alt="Slide ' + (i + 1) + '" onclick="openImageZoom(this.src)" loading="lazy" />';
|
||
}).join('');
|
||
});
|
||
})
|
||
.catch(function(err) {
|
||
btn.classList.remove('loading');
|
||
container.innerHTML = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview error: ' + escHtml(err.message) + '</span>';
|
||
});
|
||
}
|
||
|
||
function handleImageUpload(input) {
|
||
var file = input.files && input.files[0];
|
||
if (!file) return;
|
||
input.value = '';
|
||
var formData = new FormData();
|
||
formData.append('image', file);
|
||
fetch('/api/upload/image', {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: formData
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success && data.path) {
|
||
var chatInput = document.getElementById('chat-input');
|
||
var imgRef = '';
|
||
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 += '<div style="margin-bottom:6px;font-weight:700;font-size:11px">' + escHtml(sn) + '</div>';
|
||
previewHtml += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
|
||
rows.slice(0, 30).forEach(function(row, ri) {
|
||
previewHtml += '<tr>';
|
||
row.forEach(function(cell) {
|
||
var tag = ri === 0 ? 'th' : 'td';
|
||
previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '</' + tag + '>';
|
||
});
|
||
previewHtml += '</tr>';
|
||
});
|
||
if (rows.length > 30) previewHtml += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 30) + '행 더</td></tr>';
|
||
previewHtml += '</table></div>';
|
||
});
|
||
xlsxUploadPromise.then(function(d) {
|
||
var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
|
||
stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml });
|
||
});
|
||
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
|
||
};
|
||
r3.readAsArrayBuffer(xlsxFile);
|
||
});
|
||
|
||
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
|
||
} else if (/\.pdf$/.test(name)) {
|
||
var pdfFile = file;
|
||
var pdfForm = new FormData();
|
||
pdfForm.append('file', pdfFile);
|
||
var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm })
|
||
.then(function(r) { return r.json(); });
|
||
|
||
var extractPromise = new Promise(function(resolve) {
|
||
loadPdfJs(function() {
|
||
var fr = new FileReader();
|
||
fr.onload = function(e) {
|
||
pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise
|
||
.then(function(pdf) {
|
||
var pages = [];
|
||
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
|
||
Promise.all(pages).then(function(ps) {
|
||
return Promise.all(ps.map(function(p, pi) {
|
||
return p.getTextContent().then(function(tc) {
|
||
return pdfPageToText(tc.items, pi + 1, pdf.numPages);
|
||
});
|
||
}));
|
||
}).then(function(texts) {
|
||
resolve({ text: texts.join('\n\n'), pages: pdf.numPages });
|
||
}).catch(function() { resolve({ text: '', pages: 0 }); });
|
||
}).catch(function() { resolve({ text: '', pages: 0 }); });
|
||
};
|
||
fr.readAsArrayBuffer(pdfFile);
|
||
});
|
||
});
|
||
|
||
Promise.all([uploadPromise, extractPromise]).then(function(res) {
|
||
var upData = res[0], extracted = res[1];
|
||
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
|
||
var fullText = (extracted.text || '').trim();
|
||
|
||
if (fullText) {
|
||
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
|
||
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
|
||
var txtForm = new FormData();
|
||
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
|
||
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
|
||
if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
|
||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')';
|
||
if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')';
|
||
content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.';
|
||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
|
||
txtPreview: fullText.slice(0, 500),
|
||
txtUrl: txtUrl,
|
||
txtName: txtName,
|
||
pages: extracted.pages
|
||
});
|
||
}).catch(function() {
|
||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n이 파일로 무엇을 도와드릴까요?';
|
||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
|
||
txtPreview: fullText.slice(0, 500),
|
||
pages: extracted.pages
|
||
});
|
||
});
|
||
} else {
|
||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
|
||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
|
||
}
|
||
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
|
||
|
||
// 기타 (docx/pptx 등)
|
||
} else {
|
||
var otherForm = new FormData();
|
||
otherForm.append('file', file);
|
||
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success && data.path) {
|
||
insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')');
|
||
} else { alert(data.error || '파일 업로드 실패'); }
|
||
})
|
||
.catch(function(e) { alert('업로드 오류: ' + e.message); });
|
||
}
|
||
}
|
||
|
||
function insertIntoChatInput(text) {
|
||
var el = document.getElementById('chat-input');
|
||
el.value = el.value ? el.value + '\n' + text : text;
|
||
el.focus();
|
||
el.dispatchEvent(new Event('input'));
|
||
}
|
||
|
||
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
|
||
var stagedAttachments = [];
|
||
|
||
function stageAttachment(name, url, content, type, extra) {
|
||
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
|
||
var att = { id: id, name: name, url: url, content: content, type: type };
|
||
if (extra) Object.assign(att, extra);
|
||
stagedAttachments.push(att);
|
||
renderStagedAttachments();
|
||
}
|
||
|
||
function removeStagedAttachment(id) {
|
||
stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; });
|
||
renderStagedAttachments();
|
||
}
|
||
|
||
function clearStagedAttachments() {
|
||
stagedAttachments = [];
|
||
renderStagedAttachments();
|
||
}
|
||
|
||
function renderStagedAttachments() {
|
||
var container = document.getElementById('staged-attachments');
|
||
if (!container) return;
|
||
if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; }
|
||
container.style.display = 'flex';
|
||
container.innerHTML = stagedAttachments.map(function(a) {
|
||
var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝';
|
||
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px';
|
||
|
||
// PDF 카드
|
||
if (a.type === 'pdf') {
|
||
var txtPreview = a.txtPreview ? escHtml(a.txtPreview) + (a.txtPreview.length >= 500 ? '...' : '') : '';
|
||
var pagesInfo = a.pages ? ' (' + a.pages + '페이지)' : '';
|
||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||
+ '<span>📄</span>'
|
||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(a.name) + pagesInfo + '">' + escHtml(a.name) + '</span>'
|
||
+ (txtPreview ? '<button onclick="toggleStagedTxtPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">txt 미리보기</button>' : '')
|
||
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">PDF</button>' : '')
|
||
+ (a.txtUrl ? '<a href="' + escHtml(a.txtUrl) + '" download="' + escHtml(a.txtName || '') + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="txt 다운로드">📝⬇</a>' : '')
|
||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||
+ '</div>'
|
||
+ (pagesInfo ? '<div style="font-size:10px;color:var(--muted);padding-left:2px">텍스트 추출 완료' + pagesInfo + '</div>' : '')
|
||
+ (txtPreview ? '<div id="txt-pdf-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + txtPreview + '</div>' : '')
|
||
+ '<div id="pdf-preview-' + a.id + '" style="display:none"><iframe data-src="' + escHtml(a.url || '') + '" style="width:100%;height:400px;border:1px solid var(--line);border-radius:6px"></iframe></div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
// TXT 자동저장 카드 (미리보기 + 다운로드)
|
||
if (a.type === 'txt-auto') {
|
||
var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : '');
|
||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px dashed var(--brand);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||
+ '<span>📝</span>'
|
||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||
+ '<button onclick="toggleStagedTxtPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>'
|
||
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="다운로드">⬇ 저장</a>' : '')
|
||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||
+ '</div>'
|
||
+ '<div id="txt-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + preview + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
// excel 카드
|
||
if (a.type === 'excel') {
|
||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:360px">'
|
||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||
+ '<span>📊</span>'
|
||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||
+ (a.excelPreviewHtml ? '<button onclick="toggleStagedExcelPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
|
||
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block">⬇ 저장</a>' : '')
|
||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||
+ '</div>'
|
||
+ (a.excelPreviewHtml ? '<div id="excel-staged-' + a.id + '" style="display:none;max-height:260px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px">' + a.excelPreviewHtml + '</div>' : '')
|
||
+ '</div>';
|
||
}
|
||
|
||
// 기타 카드 (text, csv)
|
||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||
+ '<span>' + icon + '</span>'
|
||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
function toggleStagedPdfPreview(id) {
|
||
var el = document.getElementById('pdf-preview-' + id);
|
||
if (!el) return;
|
||
var att = stagedAttachments.find(function(a) { return a.id === id; });
|
||
if (el.style.display === 'none') {
|
||
el.style.display = 'block';
|
||
var iframe = el.querySelector('iframe');
|
||
if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정
|
||
} else {
|
||
el.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function toggleStagedTxtPreview(id) {
|
||
var el = document.getElementById('txt-preview-' + id);
|
||
if (!el) return;
|
||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
function toggleStagedTxtPdfPreview(id) {
|
||
var el = document.getElementById('txt-pdf-preview-' + id);
|
||
if (!el) return;
|
||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
// PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
|
||
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
|
||
// stagedAttachments에서 해당 PDF 찾기
|
||
var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; });
|
||
if (!att) return;
|
||
// txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용)
|
||
att.txtName = txtName;
|
||
att.txtUrl = txtUrl;
|
||
// 카드 DOM에 txt 버튼 삽입
|
||
var card = document.getElementById('card-' + att.id);
|
||
if (!card) return;
|
||
var btnRow = card.querySelector('div');
|
||
if (!btnRow) return;
|
||
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block';
|
||
var txtBtn = document.createElement('a');
|
||
txtBtn.href = txtUrl;
|
||
txtBtn.download = txtName;
|
||
txtBtn.title = txtName + ' 다운로드';
|
||
txtBtn.style.cssText = btnStyle;
|
||
txtBtn.innerHTML = '📝 txt ⬇';
|
||
// ✕ 버튼 바로 앞에 삽입
|
||
var closeBtn = btnRow.lastElementChild;
|
||
btnRow.insertBefore(txtBtn, closeBtn);
|
||
}
|
||
|
||
// ── PDF 페이지 텍스트 구조화 ────────────────────────────────────────────────
|
||
function pdfPageToText(items, pageNum, totalPages) {
|
||
// filter out empty items
|
||
var valid = items.filter(function(it) { return it.str && it.str.trim() !== ''; });
|
||
if (!valid.length) return '';
|
||
|
||
// sort top→bottom (y desc), left→right (x asc)
|
||
valid.sort(function(a, b) {
|
||
var ay = a.transform[5], by = b.transform[5];
|
||
if (Math.abs(ay - by) > 2) return by - ay;
|
||
return a.transform[4] - b.transform[4];
|
||
});
|
||
|
||
// group items into lines by y-coordinate proximity
|
||
var LINE_Y_THRESH = 3;
|
||
var lines = [];
|
||
var cur = null;
|
||
valid.forEach(function(item) {
|
||
var y = item.transform[5];
|
||
var h = item.height || 10;
|
||
if (!cur || Math.abs(y - cur.y) > LINE_Y_THRESH) {
|
||
cur = { y: y, h: h, items: [item] };
|
||
lines.push(cur);
|
||
} else {
|
||
cur.items.push(item);
|
||
if (h > cur.h) cur.h = h;
|
||
}
|
||
});
|
||
|
||
// build output: join items per line; insert blank line on large y-gaps (paragraph break)
|
||
var out = [];
|
||
if (totalPages > 1) out.push('── ' + pageNum + '페이지 ──');
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var lineText = lines[i].items.map(function(it) { return it.str; }).join('').replace(/\s+/g, ' ').trim();
|
||
if (!lineText) continue;
|
||
|
||
// paragraph break: gap > 1.4× current line height
|
||
if (i > 0) {
|
||
var gap = lines[i - 1].y - lines[i].y;
|
||
if (gap > lines[i - 1].h * 1.4) out.push('');
|
||
}
|
||
// fix hyphenation: merge "단어-\n다음줄" → "단어다음줄"
|
||
if (out.length && out[out.length - 1].endsWith('-')) {
|
||
out[out.length - 1] = out[out.length - 1].slice(0, -1) + lineText;
|
||
} else {
|
||
out.push(lineText);
|
||
}
|
||
}
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ── SheetJS / PDF.js / x-spreadsheet 동적 로드 ──────────────────────────────────────────────
|
||
var _sheetJsLoaded = false;
|
||
function loadSheetJS(cb) {
|
||
if (_sheetJsLoaded) return cb();
|
||
var s = document.createElement('script');
|
||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
|
||
s.onload = function() { _sheetJsLoaded = true; cb(); };
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
var _luckyLoaded = false;
|
||
function loadLuckysheet(cb) {
|
||
if (_luckyLoaded) return cb();
|
||
var cssList = [
|
||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css',
|
||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css',
|
||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css',
|
||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css',
|
||
];
|
||
cssList.forEach(function(href) {
|
||
var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
|
||
document.head.appendChild(l);
|
||
});
|
||
var scripts = [
|
||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js',
|
||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js',
|
||
'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js',
|
||
];
|
||
function next(i) {
|
||
if (i >= scripts.length) { _luckyLoaded = true; cb(); return; }
|
||
var s = document.createElement('script');
|
||
s.src = scripts[i]; s.onload = function() { next(i + 1); };
|
||
s.onerror = function() { next(i + 1); }; // 실패해도 진행
|
||
document.head.appendChild(s);
|
||
}
|
||
next(0);
|
||
}
|
||
|
||
var _pdfJsLoaded = false;
|
||
function loadPdfJs(cb) {
|
||
if (_pdfJsLoaded) return cb();
|
||
var s = document.createElement('script');
|
||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||
s.onload = function() {
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||
_pdfJsLoaded = true; cb();
|
||
};
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function timeAgo(ts) {
|
||
const diff = Date.now() - ts;
|
||
if (diff < 60000) return 'just now';
|
||
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
|
||
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
|
||
return `${Math.floor(diff/86400000)}d ago`;
|
||
}
|
||
|
||
function openOllamaInfo() { window.open('http://localhost:11434','_blank'); }
|
||
|
||
// ---- Skills management (v2 - local skills system) ----
|
||
let skillsCache = [];
|
||
let editingSkillId = null;
|
||
|
||
let skillsTargetUser = ''; // '' = global (admin) or own username (regular user)
|
||
|
||
async function initSkillsUserSelector() {
|
||
const selector = document.getElementById('skills-user-selector');
|
||
if (!selector) return;
|
||
if (!currentUser) { selector.style.display = 'none'; return; }
|
||
if (currentUser.role !== 'admin') {
|
||
selector.style.display = 'none';
|
||
skillsTargetUser = currentUser.username || '';
|
||
return;
|
||
}
|
||
selector.style.display = 'block';
|
||
try {
|
||
const data = await api('/api/users');
|
||
const sel = document.getElementById('skills-target-user');
|
||
if (!sel || !data.users) return;
|
||
const opts = ['<option value="">글로벌 기본값</option>'];
|
||
for (const u of data.users) {
|
||
const selected = u.username === skillsTargetUser ? ' selected' : '';
|
||
opts.push(`<option value="${escHtml(u.username)}"${selected}>${escHtml(u.username)}</option>`);
|
||
}
|
||
sel.innerHTML = opts.join('');
|
||
} catch {}
|
||
}
|
||
|
||
function onSkillsUserChange() {
|
||
const sel = document.getElementById('skills-target-user');
|
||
skillsTargetUser = sel ? sel.value : '';
|
||
loadInstalledSkills();
|
||
}
|
||
|
||
async function loadInstalledSkills() {
|
||
const el = document.getElementById('skills-list');
|
||
el.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px"><span class="spinner"></span> Loading...</div>';
|
||
try {
|
||
const effectiveUser = skillsTargetUser || currentUser?.username || null;
|
||
const url = effectiveUser ? `/api/skills?username=${encodeURIComponent(effectiveUser)}` : '/api/skills';
|
||
const data = await api(url);
|
||
const skills = data.skills || [];
|
||
skillsCache = skills;
|
||
if (skills.length === 0) {
|
||
el.innerHTML = '<div class="empty-state" style="padding:16px">No skills yet.<br>Click "+ Create" to make one!</div>';
|
||
} else {
|
||
el.innerHTML = skills.map(s => {
|
||
const skillEmoji = (s.emoji && s.emoji !== '🦞' && s.emoji !== '⚠️') ? s.emoji : '🧩';
|
||
const globalBadge = s.globalOnly ? '<span style="font-size:9px;opacity:.6;margin-left:4px">[전체]</span>' : '';
|
||
return `
|
||
<div class="skill-card ${s.enabled ? 'enabled' : ''}" data-skill-id="${escHtml(s.id)}" onclick="toggleSkill('${escHtml(s.id)}')">
|
||
<div class="skill-card-top">
|
||
<div class="skill-card-emoji">${escHtml(skillEmoji)}</div>
|
||
<div class="skill-card-info">
|
||
<div class="skill-card-name">${escHtml(s.name)}${globalBadge}</div>
|
||
<div class="skill-card-desc">${escHtml(s.description || 'No description')}</div>
|
||
</div>
|
||
<div class="skill-card-actions">
|
||
<button class="skill-mini-btn" onclick="event.stopPropagation();editSkill('${escHtml(s.id)}')" title="Edit" aria-label="Edit skill">✎</button>
|
||
<button class="skill-remove-btn" onclick="event.stopPropagation();deleteSkill('${escHtml(s.id)}')" title="Delete" aria-label="Delete skill">✕</button>
|
||
<div class="skill-card-dot"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
renderSkillEligibility(skills);
|
||
}
|
||
} catch (err) {
|
||
el.innerHTML = `<div style="padding:12px;color:var(--err);font-size:11px">Error: ${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function toggleSkill(id) {
|
||
const skillMeta = (skillsCache || []).find(s => s.id === id);
|
||
if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) {
|
||
addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.');
|
||
return;
|
||
}
|
||
// globalOnly 스킬은 per-user 모드에서 토글 불가 (서버가 전역으로 처리)
|
||
if (skillMeta?.globalOnly && skillsTargetUser) {
|
||
addProcessEntry('error', `"${skillMeta.name}" 스킬은 글로벌 전용입니다.`);
|
||
return;
|
||
}
|
||
try {
|
||
const effectiveUser = skillsTargetUser || currentUser?.username || null;
|
||
const body = effectiveUser ? JSON.stringify({ username: effectiveUser }) : undefined;
|
||
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST', body });
|
||
if (!res.success) {
|
||
addProcessEntry('error', res.error || 'Failed to toggle skill.');
|
||
return;
|
||
}
|
||
const label = res.targetUser ? ` (${res.targetUser})` : '';
|
||
addProcessEntry('info', `Skill "${res.skill.name}"${label}: ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
|
||
loadInstalledSkills();
|
||
} catch (err) {
|
||
addProcessEntry('error', `Failed to toggle skill: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function deleteSkill(id) {
|
||
if (!confirm(`Delete skill "${id}"?`)) return;
|
||
try {
|
||
const res = await api(`/api/skills/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
if (res.success) {
|
||
addProcessEntry('info', `Skill "${id}" deleted.`);
|
||
loadInstalledSkills();
|
||
} else {
|
||
alert(`Delete failed: ${res.error}`);
|
||
}
|
||
} catch (err) {
|
||
alert(`Error: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function openCreateSkillModal() {
|
||
editingSkillId = null;
|
||
document.getElementById('skill-modal-title').textContent = '스킬 생성';
|
||
document.getElementById('skill-modal-save-btn').textContent = '스킬 생성';
|
||
document.getElementById('skill-modal-name').value = '';
|
||
document.getElementById('skill-modal-emoji').value = '';
|
||
document.getElementById('skill-modal-desc').value = '';
|
||
document.getElementById('skill-modal-instructions').value = '';
|
||
document.getElementById('skill-modal').style.display = 'flex';
|
||
document.getElementById('skill-modal-name').focus();
|
||
}
|
||
|
||
async function editSkill(id) {
|
||
try {
|
||
const res = await api(`/api/skills/${encodeURIComponent(id)}`);
|
||
if (!res.success) { alert('Skill not found'); return; }
|
||
const s = res.skill;
|
||
editingSkillId = id;
|
||
document.getElementById('skill-modal-title').textContent = '스킬 편집';
|
||
document.getElementById('skill-modal-save-btn').textContent = '변경 저장';
|
||
document.getElementById('skill-modal-name').value = s.name || '';
|
||
document.getElementById('skill-modal-emoji').value = s.emoji || '';
|
||
document.getElementById('skill-modal-desc').value = s.description || '';
|
||
document.getElementById('skill-modal-instructions').value = s.instructions || '';
|
||
document.getElementById('skill-modal').style.display = 'flex';
|
||
document.getElementById('skill-modal-name').focus();
|
||
} catch (err) {
|
||
alert(`Error loading skill: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function closeSkillModal() {
|
||
document.getElementById('skill-modal').style.display = 'none';
|
||
editingSkillId = null;
|
||
}
|
||
|
||
async function saveSkillFromModal() {
|
||
const name = document.getElementById('skill-modal-name').value.trim();
|
||
const rawEmoji = document.getElementById('skill-modal-emoji').value.trim();
|
||
const emoji = (rawEmoji === '🧩' || rawEmoji === '🦞' || rawEmoji === '⚠️') ? rawEmoji : '🧩';
|
||
const description = document.getElementById('skill-modal-desc').value.trim();
|
||
const instructions = document.getElementById('skill-modal-instructions').value.trim();
|
||
|
||
if (!name) { alert('Name is required'); return; }
|
||
if (!instructions) { alert('Instructions are required'); return; }
|
||
|
||
const btn = document.getElementById('skill-modal-save-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = '저장 중...';
|
||
|
||
try {
|
||
let res;
|
||
if (editingSkillId) {
|
||
// Update existing
|
||
res = await api(`/api/skills/${encodeURIComponent(editingSkillId)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ name, emoji, description, instructions }),
|
||
});
|
||
} else {
|
||
// Create new
|
||
res = await api('/api/skills', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, emoji, description, instructions }),
|
||
});
|
||
}
|
||
if (res.success) {
|
||
addProcessEntry('info', `Skill "${name}" ${editingSkillId ? 'updated' : 'created'}!`);
|
||
closeSkillModal();
|
||
loadInstalledSkills();
|
||
} else {
|
||
alert(`Failed: ${res.error}`);
|
||
}
|
||
} catch (err) {
|
||
alert(`Error: ${err.message}`);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = editingSkillId ? '변경 저장' : '스킬 생성';
|
||
}
|
||
}
|
||
|
||
// ---- Stop Button ----
|
||
let currentAbortController = null;
|
||
|
||
function handleSendStop() {
|
||
if (isThinking) stopGeneration();
|
||
else sendChat();
|
||
}
|
||
|
||
function stopGeneration() {
|
||
if (currentAbortController) {
|
||
currentAbortController.abort();
|
||
currentAbortController = null;
|
||
}
|
||
}
|
||
|
||
function setButtonState(thinking) {
|
||
const sendIcon = document.getElementById('send-icon');
|
||
const stopIcon = document.getElementById('stop-icon');
|
||
const btn = document.getElementById('send-btn');
|
||
if (!btn) return;
|
||
if (thinking) {
|
||
if (sendIcon) sendIcon.style.display = 'none';
|
||
if (stopIcon) stopIcon.style.display = '';
|
||
btn.style.background = 'var(--err)';
|
||
btn.title = 'Stop generation';
|
||
} else {
|
||
if (sendIcon) sendIcon.style.display = '';
|
||
if (stopIcon) stopIcon.style.display = 'none';
|
||
btn.style.background = '';
|
||
btn.title = 'Send';
|
||
}
|
||
}
|
||
|
||
|
||
// ---- Context Pinning ----
|
||
const MAX_PINS = 3;
|
||
let contextPinMode = false;
|
||
let pinnedMessageIndices = new Set();
|
||
let confirmedPins = []; // Array of { role, content } sent with each request
|
||
|
||
function toggleContextPinMode() {
|
||
const pop = document.getElementById('context-pin-popover');
|
||
if (!pop) return;
|
||
const isOpen = pop.classList.contains('open');
|
||
// Close other popovers
|
||
document.getElementById('quick-mode-popover')?.classList.remove('open');
|
||
if (isOpen) {
|
||
cancelContextPin();
|
||
} else {
|
||
contextPinMode = true;
|
||
pop.classList.add('open');
|
||
document.body.classList.add('context-pin-active');
|
||
// Reset selection to currently confirmed pins
|
||
pinnedMessageIndices = new Set();
|
||
for (const pin of confirmedPins) {
|
||
const idx = chatHistory.findIndex(m => m.content === pin.content && m.role === pin.role);
|
||
if (idx >= 0) pinnedMessageIndices.add(idx);
|
||
}
|
||
updatePinUI();
|
||
renderChatMessages();
|
||
}
|
||
}
|
||
|
||
function cancelContextPin() {
|
||
contextPinMode = false;
|
||
pinnedMessageIndices = new Set();
|
||
document.getElementById('context-pin-popover')?.classList.remove('open');
|
||
document.body.classList.remove('context-pin-active');
|
||
renderChatMessages();
|
||
}
|
||
|
||
function confirmContextPin() {
|
||
confirmedPins = [];
|
||
for (const idx of pinnedMessageIndices) {
|
||
const msg = chatHistory[idx];
|
||
if (msg) confirmedPins.push({ role: msg.role === 'user' ? 'user' : 'assistant', content: msg.content });
|
||
}
|
||
contextPinMode = false;
|
||
document.getElementById('context-pin-popover')?.classList.remove('open');
|
||
document.body.classList.remove('context-pin-active');
|
||
updatePinBadge();
|
||
renderChatMessages();
|
||
if (confirmedPins.length > 0) {
|
||
addProcessEntry('info', `${confirmedPins.length} message(s) pinned to context.`);
|
||
} else {
|
||
addProcessEntry('info', 'Context pins cleared.');
|
||
}
|
||
}
|
||
|
||
function togglePinMessage(index) {
|
||
if (!contextPinMode) return;
|
||
if (pinnedMessageIndices.has(index)) {
|
||
pinnedMessageIndices.delete(index);
|
||
} else if (pinnedMessageIndices.size < MAX_PINS) {
|
||
pinnedMessageIndices.add(index);
|
||
}
|
||
updatePinUI();
|
||
renderChatMessages();
|
||
}
|
||
|
||
function removePinFromList(index) {
|
||
pinnedMessageIndices.delete(index);
|
||
updatePinUI();
|
||
renderChatMessages();
|
||
}
|
||
|
||
function updatePinUI() {
|
||
const countEl = document.getElementById('context-pin-count');
|
||
const listEl = document.getElementById('context-pin-list');
|
||
if (countEl) countEl.textContent = `${pinnedMessageIndices.size}/${MAX_PINS} selected`;
|
||
if (listEl) {
|
||
if (pinnedMessageIndices.size === 0) {
|
||
listEl.innerHTML = '<div style="font-size:11px;color:var(--muted);text-align:center;padding:8px">Click messages in chat to select them</div>';
|
||
} else {
|
||
listEl.innerHTML = Array.from(pinnedMessageIndices).map(idx => {
|
||
const msg = chatHistory[idx];
|
||
if (!msg) return '';
|
||
const icon = msg.role === 'user' ? '??' : '??';
|
||
const text = msg.content.slice(0, 60) + (msg.content.length > 60 ? '...' : '');
|
||
return `<div class="pin-card">
|
||
<span>${icon}</span>
|
||
<div class="pin-card-text">${escHtml(text)}</div>
|
||
<button class="pin-card-remove" onclick="removePinFromList(${idx})">×</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
function updatePinBadge() {
|
||
const label = document.getElementById('context-pin-label');
|
||
if (label) {
|
||
label.textContent = confirmedPins.length > 0 ? `컨텍스트 (${confirmedPins.length})` : '컨텍스트';
|
||
}
|
||
const btn = document.getElementById('context-pin-btn');
|
||
if (btn) {
|
||
btn.style.borderColor = confirmedPins.length > 0 ? '#86d4a8' : '';
|
||
btn.style.background = confirmedPins.length > 0 ? '#f0faf4' : '';
|
||
btn.style.color = confirmedPins.length > 0 ? '#0a6b3d' : '';
|
||
}
|
||
}
|
||
|
||
function getInitialTheme() {
|
||
try {
|
||
const saved = localStorage.getItem(THEME_KEY);
|
||
if (saved === 'light' || saved === 'dark') return saved;
|
||
} catch {}
|
||
return 'light';
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
const resolved = theme === 'dark' ? 'dark' : 'light';
|
||
document.documentElement.setAttribute('data-theme', resolved);
|
||
const toggle = document.getElementById('theme-toggle');
|
||
if (toggle) {
|
||
toggle.setAttribute('data-theme-state', resolved);
|
||
const title = resolved === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
|
||
toggle.title = title;
|
||
toggle.setAttribute('aria-label', title);
|
||
}
|
||
try { localStorage.setItem(THEME_KEY, resolved); } catch {}
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||
}
|
||
|
||
// ---- Boot ----
|
||
document.addEventListener('click', (e) => {
|
||
const pop = document.getElementById('quick-mode-popover');
|
||
const btn = document.getElementById('quick-mode-btn');
|
||
if (!pop || !btn) return;
|
||
if (!pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('open');
|
||
// Also close context pin popover
|
||
const pinPop = document.getElementById('context-pin-popover');
|
||
const pinBtn = document.getElementById('context-pin-btn');
|
||
if (pinPop && pinBtn && !pinPop.contains(e.target) && !pinBtn.contains(e.target)) {
|
||
// Don't close if in pin mode and clicking on messages
|
||
if (contextPinMode && e.target.closest && e.target.closest('.msg')) return;
|
||
}
|
||
});
|
||
|
||
// --- SCHEDULER MANAGEMENT ---------------------------------------------------
|
||
|
||
let schedules = [];
|
||
let editingScheduleId = null;
|
||
|
||
async function refreshSchedules() {
|
||
try {
|
||
const result = await api('/api/schedules');
|
||
if (result.success && Array.isArray(result.schedules)) {
|
||
schedules = result.schedules;
|
||
renderScheduleList();
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load schedules:', err);
|
||
}
|
||
}
|
||
|
||
function renderScheduleList() {
|
||
const list = document.getElementById('schedule-list');
|
||
const count = document.getElementById('schedule-count');
|
||
if (!list) return;
|
||
|
||
if (schedules.length === 0) {
|
||
list.innerHTML = '<div class="empty-state" style="text-align:center;color:var(--muted);padding:40px 20px">No schedules yet. <strong>+ 새 일정</strong> to get started.</div>';
|
||
if (count) count.textContent = '0 schedules';
|
||
return;
|
||
}
|
||
|
||
if (count) count.textContent = `${schedules.length} schedule${schedules.length !== 1 ? 's' : ''}`;
|
||
|
||
list.innerHTML = schedules.map(job => {
|
||
const nextRun = job.next_run ? new Date(job.next_run).toLocaleString() : 'Never';
|
||
const lastRun = job.last_run ? new Date(job.last_run).toLocaleString() : 'Never';
|
||
const statusClass = job.status === 'active' ? 'status-active' : job.status === 'paused' ? 'status-paused' : 'status-failed';
|
||
|
||
return `
|
||
<div style="display:flex;align-items:start;justify-content:space-between;gap:12px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;cursor:pointer" onclick="editSchedule('${job.id}')">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<div style="font-weight:700;font-size:13px">${job.name || 'Untitled'}</div>
|
||
<span class="${statusClass}" style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px;${job.status === 'active' ? 'background:#c8f0c4;color:#0d5c2f' : job.status === 'paused' ? 'background:#fff4d6;color:#7d5700' : 'background:#ffd6d6;color:#8b0000'}">${job.status || 'unknown'}</span>
|
||
</div>
|
||
<div style="font-size:12px;color:var(--muted);line-height:1.4;margin-bottom:6px">${job.prompt ? job.prompt.slice(0, 60) + (job.prompt.length > 60 ? '...' : '') : 'No prompt'}</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:11px;color:var(--muted)">
|
||
<div><strong>Next:</strong> ${nextRun}</div>
|
||
<div><strong>Last:</strong> ${lastRun}</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px;flex-shrink:0">
|
||
<button onclick="event.stopPropagation(); runScheduleNow('${job.id}')" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Run now">Run Now</button>
|
||
<button onclick="event.stopPropagation(); toggleSchedulePause('${job.id}')" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Toggle pause">${job.status === 'active' ? 'Pause' : 'Resume'}</button>
|
||
<button onclick="event.stopPropagation(); deleteSchedule('${job.id}')" style="border:1px solid #ff6b6b;background:#ffe0e0;color:#8b0000;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Delete">삭제</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function openScheduleCreateModal() {
|
||
editingScheduleId = null;
|
||
document.getElementById('schedule-modal-title').textContent = '일정 생성';
|
||
document.getElementById('schedule-save-btn').textContent = '일정 생성';
|
||
document.getElementById('schedule-name').value = '';
|
||
document.getElementById('schedule-pattern').value = '';
|
||
document.getElementById('schedule-prompt').value = '';
|
||
document.getElementById('schedule-timezone').value = 'UTC';
|
||
document.getElementById('schedule-channel').value = 'web';
|
||
document.getElementById('schedule-pattern-preview').style.display = 'none';
|
||
document.getElementById('schedule-modal').style.display = 'flex';
|
||
}
|
||
|
||
function editSchedule(jobId) {
|
||
const job = schedules.find(j => j.id === jobId);
|
||
if (!job) return;
|
||
|
||
editingScheduleId = jobId;
|
||
document.getElementById('schedule-modal-title').textContent = '일정 편집';
|
||
document.getElementById('schedule-save-btn').textContent = '변경 저장';
|
||
document.getElementById('schedule-name').value = job.name || '';
|
||
document.getElementById('schedule-pattern').value = job.cron || job.run_at || '';
|
||
document.getElementById('schedule-prompt').value = job.prompt || '';
|
||
document.getElementById('schedule-timezone').value = job.timezone || 'UTC';
|
||
document.getElementById('schedule-channel').value = job.delivery_channel || 'web';
|
||
document.getElementById('schedule-pattern-preview').style.display = 'none';
|
||
document.getElementById('schedule-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeScheduleModal() {
|
||
document.getElementById('schedule-modal').style.display = 'none';
|
||
editingScheduleId = null;
|
||
}
|
||
|
||
async function parseSchedulePattern() {
|
||
const pattern = document.getElementById('schedule-pattern').value.trim();
|
||
const timezone = document.getElementById('schedule-timezone').value;
|
||
if (!pattern) {
|
||
alert('Enter a schedule pattern (e.g., "daily at 09:00" or "0 9 * * *")');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await api('/api/schedules/parse', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ text: pattern, timezone })
|
||
});
|
||
|
||
const preview = document.getElementById('schedule-pattern-preview');
|
||
if (result.success) {
|
||
preview.textContent = `✓ ${result.preview || result.human_text || 'Valid pattern'}`;
|
||
preview.style.display = 'block';
|
||
preview.style.color = 'var(--muted)';
|
||
} else {
|
||
preview.textContent = `⚠ ${result.error || 'Invalid pattern'}`;
|
||
preview.style.display = 'block';
|
||
preview.style.color = '#ff6b6b';
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('schedule-pattern-preview').textContent = '⚠ Parse failed';
|
||
document.getElementById('schedule-pattern-preview').style.display = 'block';
|
||
document.getElementById('schedule-pattern-preview').style.color = '#ff6b6b';
|
||
}
|
||
}
|
||
|
||
async function saveSchedule() {
|
||
const name = document.getElementById('schedule-name').value.trim();
|
||
const pattern = document.getElementById('schedule-pattern').value.trim();
|
||
const prompt = document.getElementById('schedule-prompt').value.trim();
|
||
const timezone = document.getElementById('schedule-timezone').value;
|
||
const channel = document.getElementById('schedule-channel').value;
|
||
|
||
if (!name) return alert('Schedule name is required');
|
||
if (!pattern) return alert('Schedule pattern is required');
|
||
if (!prompt) return alert('Prompt/action is required');
|
||
|
||
const method = editingScheduleId ? 'PUT' : 'POST';
|
||
const path = editingScheduleId ? `/api/schedules/${editingScheduleId}` : '/api/schedules';
|
||
|
||
try {
|
||
const result = await api(path, {
|
||
method,
|
||
body: JSON.stringify({
|
||
name,
|
||
pattern,
|
||
prompt,
|
||
timezone,
|
||
delivery_channel: channel,
|
||
confirm: true // Confirmation (as per scheduler rules)
|
||
})
|
||
});
|
||
|
||
if (result.success) {
|
||
closeScheduleModal();
|
||
await refreshSchedules();
|
||
} else {
|
||
alert('Error: ' + (result.error || 'Unknown error'));
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to save schedule: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function deleteSchedule(jobId) {
|
||
if (!confirm('Delete this schedule? This action cannot be undone.')) return;
|
||
|
||
try {
|
||
const result = await api(`/api/schedules/${jobId}`, {
|
||
method: 'DELETE',
|
||
body: JSON.stringify({ confirm: true })
|
||
});
|
||
|
||
if (result.success) {
|
||
await refreshSchedules();
|
||
} else {
|
||
alert('Error: ' + (result.error || 'Failed to delete'));
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to delete schedule: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function toggleSchedulePause(jobId) {
|
||
const job = schedules.find(j => j.id === jobId);
|
||
if (!job) return;
|
||
|
||
const newStatus = job.status === 'active' ? 'paused' : 'active';
|
||
|
||
try {
|
||
const result = await api(`/api/schedules/${jobId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ status: newStatus })
|
||
});
|
||
|
||
if (result.success) {
|
||
await refreshSchedules();
|
||
} else {
|
||
alert('Error: ' + (result.error || 'Failed to update'));
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to toggle pause: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function runScheduleNow(jobId) {
|
||
try {
|
||
const result = await api(`/api/schedules/${jobId}/run`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({})
|
||
});
|
||
|
||
if (result.success) {
|
||
await refreshSchedules();
|
||
alert('Schedule running now...');
|
||
} else {
|
||
alert('Error: ' + (result.error || 'Failed to run'));
|
||
}
|
||
} catch (err) {
|
||
alert('Failed to run schedule: ' + err.message);
|
||
}
|
||
}
|
||
|
||
loadChatSessions();
|
||
applyTheme(getInitialTheme());
|
||
setupProcessAndRightScrollTracking();
|
||
updateHeartbeatUI();
|
||
connectWS();
|
||
refreshAll();
|
||
updateQuickModeUI();
|
||
updateQueuedPromptUI();
|
||
setInterval(checkStatus, 10000);
|
||
setInterval(loadSystemStats, 3000);
|
||
setInterval(loadApprovals, 5000);
|
||
setMode('chat'); // Start in chat mode
|
||
setSidebarTab('jobs');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BACKGROUND TASKS KANBAN SYSTEM
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let bgtTasks = []; // all task records from server
|
||
let bgtOpenTaskId = null; // currently open panel task id
|
||
let bgtHeartbeatConfig = { enabled: true, interval_minutes: 10 };
|
||
|
||
function openNewBgTaskModal() {
|
||
const modal = document.getElementById('bgt-new-task-modal');
|
||
if (modal) { modal.style.display = 'flex'; document.getElementById('bgt-new-title').focus(); }
|
||
}
|
||
function closeNewBgTaskModal() {
|
||
const modal = document.getElementById('bgt-new-task-modal');
|
||
if (modal) modal.style.display = 'none';
|
||
document.getElementById('bgt-new-title').value = '';
|
||
document.getElementById('bgt-new-prompt').value = '';
|
||
}
|
||
async function submitNewBgTask() {
|
||
const title = document.getElementById('bgt-new-title').value.trim();
|
||
const prompt = document.getElementById('bgt-new-prompt').value.trim();
|
||
if (!title || !prompt) { alert('제목과 작업 내용을 입력해주세요.'); return; }
|
||
const btn = document.getElementById('bgt-new-submit-btn');
|
||
btn.textContent = '시작 중...'; btn.disabled = true;
|
||
try {
|
||
const data = await api('/api/bg-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, prompt }) });
|
||
if (data.success) {
|
||
closeNewBgTaskModal();
|
||
await refreshBgTasks();
|
||
} else {
|
||
alert('태스크 생성 실패: ' + (data.error || 'unknown'));
|
||
}
|
||
} catch (err) {
|
||
alert('오류: ' + err.message);
|
||
} finally {
|
||
btn.textContent = '▶ 시작'; btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openBgtHeartbeatModal() {
|
||
const modal = document.getElementById('bgt-heartbeat-modal');
|
||
const enabledEl = document.getElementById('bgt-hb-enabled');
|
||
const intervalEl = document.getElementById('bgt-hb-interval');
|
||
if (!modal || !enabledEl || !intervalEl) return;
|
||
enabledEl.checked = !!bgtHeartbeatConfig.enabled;
|
||
intervalEl.value = String(Math.max(1, Number(bgtHeartbeatConfig.interval_minutes) || 10));
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeBgtHeartbeatModal() {
|
||
const modal = document.getElementById('bgt-heartbeat-modal');
|
||
if (modal) modal.style.display = 'none';
|
||
}
|
||
|
||
async function saveBgtHeartbeatConfig() {
|
||
const enabledEl = document.getElementById('bgt-hb-enabled');
|
||
const intervalEl = document.getElementById('bgt-hb-interval');
|
||
if (!enabledEl || !intervalEl) return;
|
||
|
||
const enabled = !!enabledEl.checked;
|
||
const interval = Math.max(1, Math.min(1440, Number(intervalEl.value) || 10));
|
||
try {
|
||
const data = await api('/api/bg-tasks/heartbeat/config', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ enabled, interval_minutes: interval }),
|
||
});
|
||
if (data?.success && data.config) {
|
||
bgtHeartbeatConfig = data.config;
|
||
renderBgTasks();
|
||
closeBgtHeartbeatModal();
|
||
bgtToast('Heartbeat updated', enabled ? `Every ${interval} minute(s)` : 'Task heartbeat disabled');
|
||
return;
|
||
}
|
||
bgtToast('Heartbeat update failed', String(data?.error || 'Unknown error'));
|
||
} catch (err) {
|
||
bgtToast('Heartbeat update failed', String(err?.message || err));
|
||
}
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return;
|
||
const modal = document.getElementById('bgt-heartbeat-modal');
|
||
if (modal && modal.style.display === 'flex') closeBgtHeartbeatModal();
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
const modal = document.getElementById('bgt-heartbeat-modal');
|
||
if (!modal || modal.style.display !== 'flex') return;
|
||
if (e.target === modal) closeBgtHeartbeatModal();
|
||
});
|
||
|
||
// --- Columns config ---------------------------------------------------------
|
||
const BGT_COLUMNS = [
|
||
{ key: 'running', label: '● In Progress', color: '#0d4faf', bg: '#eaf2ff' },
|
||
{ key: 'queued', label: '◷ Queued', color: '#7c4d00', bg: '#fff8e1' },
|
||
{ key: 'paused', label: '‖ Paused', color: '#555', bg: '#f5f5f5' },
|
||
{ key: 'stalled', label: '▲ Stalled', color: '#9c1a1a', bg: '#fff0f0' },
|
||
{ key: 'needs_assistance', label: '⚠️ Needs You', color: '#6d2d9e', bg: '#f5eeff' },
|
||
{ key: 'complete', label: '✓ Complete', color: '#1a6e35', bg: '#efffea' },
|
||
{ key: 'failed', label: '✕ Failed', color: '#9c1a1a', bg: '#fff0f0' },
|
||
];
|
||
|
||
const STATUS_ICON = {
|
||
running: '●',
|
||
queued: '◷',
|
||
paused: '‖',
|
||
stalled: '▲',
|
||
needs_assistance: '⚠️',
|
||
complete: '✓',
|
||
failed: '✕',
|
||
};
|
||
|
||
// --- Fetch & Render ----------------------------------------------------------
|
||
async function refreshBgTasks() {
|
||
try {
|
||
const data = await api('/api/bg-tasks');
|
||
if (data.success) {
|
||
bgtTasks = data.tasks || [];
|
||
bgtHeartbeatConfig = data.heartbeatConfig || bgtHeartbeatConfig;
|
||
}
|
||
} catch (err) {
|
||
console.error('[BGT] refreshBgTasks error:', err);
|
||
}
|
||
renderBgTasks();
|
||
}
|
||
|
||
function renderBgTasks() {
|
||
const board = document.getElementById('bgt-board');
|
||
if (!board) return;
|
||
|
||
// Update count badge
|
||
const countEl = document.getElementById('bgt-count');
|
||
if (countEl) {
|
||
const active = bgtTasks.filter(t => t.status !== 'complete' && t.status !== 'failed').length;
|
||
countEl.textContent = `${active} active`;
|
||
}
|
||
|
||
// Update heartbeat label
|
||
const hbLabel = document.getElementById('bgt-heartbeat-label');
|
||
if (hbLabel) {
|
||
const mins = bgtHeartbeatConfig.interval_minutes || 10;
|
||
const on = bgtHeartbeatConfig.enabled;
|
||
hbLabel.textContent = on ? `• heartbeat: ${mins}min` : '• heartbeat: off';
|
||
hbLabel.style.color = on ? 'var(--brand)' : 'var(--muted)';
|
||
}
|
||
|
||
// Only render non-empty columns
|
||
const byStatus = {};
|
||
for (const col of BGT_COLUMNS) byStatus[col.key] = [];
|
||
for (const t of bgtTasks) {
|
||
if (byStatus[t.status]) byStatus[t.status].push(t);
|
||
else if (byStatus['stalled']) byStatus['stalled'].push(t);
|
||
}
|
||
|
||
board.innerHTML = '';
|
||
for (const col of BGT_COLUMNS) {
|
||
const tasks = byStatus[col.key];
|
||
if (tasks.length === 0 && (col.key === 'complete' || col.key === 'failed' || col.key === 'stalled' || col.key === 'needs_assistance')) continue;
|
||
const colEl = document.createElement('div');
|
||
colEl.style.cssText = 'min-width:240px;max-width:280px;flex:0 0 auto;display:flex;flex-direction:column;gap:0;';
|
||
colEl.innerHTML = `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||
<span style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;color:${col.color}">${col.label}</span>
|
||
<span style="font-size:10px;background:${col.bg};color:${col.color};border-radius:999px;padding:1px 8px;font-weight:700">${tasks.length}</span>
|
||
</div>
|
||
<div class="bgt-col-cards" style="display:flex;flex-direction:column;gap:8px;min-height:60px">${tasks.map(t => bgtCardHTML(t, col)).join('')}</div>
|
||
`;
|
||
board.appendChild(colEl);
|
||
if (tasks.length === 0) {
|
||
// Empty placeholder
|
||
colEl.querySelector('.bgt-col-cards').innerHTML = `<div style="text-align:center;padding:20px 8px;color:var(--muted);font-size:11px;border:1.5px dashed var(--line);border-radius:10px">No ${col.label.split(' ').slice(1).join(' ').toLowerCase()} tasks</div>`;
|
||
}
|
||
}
|
||
|
||
// If no tasks at all
|
||
if (bgtTasks.length === 0) {
|
||
board.innerHTML = `<div style="text-align:center;padding:60px 24px;color:var(--muted);font-size:13px;flex:1">
|
||
<div style="font-size:36px;margin-bottom:12px">작업</div>
|
||
<div style="font-weight:700;margin-bottom:4px">No background tasks yet</div>
|
||
<div style="font-size:12px">Ask CherryClaw to do something in the background and it'll appear here.</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function bgtCardHTML(t, col) {
|
||
const mins = Math.round((Date.now() - (t.lastProgressAt || t.startedAt)) / 60000);
|
||
const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.round(mins/60)}h ago`;
|
||
const stepsDone = (t.plan || []).filter(s => s.status === 'done' || s.status === 'skipped').length;
|
||
const totalSteps = (t.plan || []).length;
|
||
const pct = totalSteps > 0 ? Math.round(stepsDone / totalSteps * 100) : 0;
|
||
const currentStep = (t.plan || [])[t.currentStepIndex || 0];
|
||
const channel = t.channel === 'telegram' ? 'Telegram' : 'Web UI';
|
||
|
||
return `
|
||
<div onclick="openBgtPanel('${escHtml(t.id)}')" data-bgt-id="${escHtml(t.id)}" style="
|
||
background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 14px;
|
||
cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s;
|
||
${bgtOpenTaskId === t.id ? 'border-color:var(--brand);box-shadow:0 0 0 2px rgba(90,145,255,0.15);' : ''}
|
||
" onmouseover="this.style.borderColor='var(--brand)'" onmouseout="this.style.borderColor='${bgtOpenTaskId===t.id?'var(--brand)':'var(--line)'}'">
|
||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:6px">
|
||
<span style="font-size:13px;font-weight:700;line-height:1.3;flex:1;min-width:0">${escHtml(t.title || 'Untitled Task')}</span>
|
||
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
|
||
<button
|
||
onclick="event.stopPropagation(); bgtDeleteTask('${escHtml(t.id)}', '${escHtml(t.status)}')"
|
||
title="Remove task"
|
||
style="border:1px solid #fca5a5;background:#fff0f0;color:#9c1a1a;border-radius:7px;padding:1px 6px;font-size:11px;font-weight:700;cursor:pointer"
|
||
>✕</button>
|
||
<span style="font-size:14px;flex-shrink:0">${STATUS_ICON[t.status] || '?'}</span>
|
||
</div>
|
||
</div>
|
||
${currentStep ? `<div style="font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.4">Step ${(t.currentStepIndex||0)+1}/${totalSteps}: ${escHtml(currentStep.description.slice(0,60))}${currentStep.description.length>60?'…':''}</div>` : ''}
|
||
${totalSteps > 0 ? `
|
||
<div style="background:var(--line);border-radius:999px;height:3px;margin-bottom:8px;overflow:hidden">
|
||
<div style="background:${col.color};width:${pct}%;height:100%;border-radius:999px;transition:width 0.4s"></div>
|
||
</div>` : ''}
|
||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||
<span style="font-size:10px;color:var(--muted)">${channel} ${timeAgo}</span>
|
||
${t.status === 'needs_assistance' ? `<span style="font-size:10px;background:#f5eeff;color:#6d2d9e;border-radius:999px;padding:1px 8px;font-weight:700">Needs you</span>` : ''}
|
||
${t.status === 'paused' ? `<span style="font-size:10px;background:#f5f5f5;color:#555;border-radius:999px;padding:1px 8px;font-weight:700">${t.pauseReason || 'paused'}</span>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// --- Side Panel --------------------------------------------------------------
|
||
async function openBgtPanel(taskId) {
|
||
bgtOpenTaskId = taskId;
|
||
const panel = document.getElementById('bgt-panel');
|
||
if (panel) panel.style.display = 'flex';
|
||
// Clear chat input when switching tasks
|
||
const chatInput = document.getElementById('bgt-chat-input');
|
||
if (chatInput) { chatInput.value = ''; chatInput.disabled = false; chatInput.style.opacity = ''; }
|
||
await bgtRefreshOpenPanel();
|
||
// Highlight the card
|
||
renderBgTasks();
|
||
}
|
||
|
||
function closeBgtPanel() {
|
||
bgtOpenTaskId = null;
|
||
const panel = document.getElementById('bgt-panel');
|
||
if (panel) panel.style.display = 'none';
|
||
renderBgTasks();
|
||
}
|
||
|
||
async function bgtRefreshOpenPanel() {
|
||
if (!bgtOpenTaskId) return;
|
||
let task;
|
||
try {
|
||
const data = await api(`/api/bg-tasks/${bgtOpenTaskId}`);
|
||
if (data.success) task = data.task;
|
||
} catch {}
|
||
if (!task) { task = bgtTasks.find(t => t.id === bgtOpenTaskId); }
|
||
if (!task) return;
|
||
|
||
const titleEl = document.getElementById('bgt-panel-title');
|
||
if (titleEl) titleEl.textContent = task.title || '작업 상세';
|
||
|
||
const pauseBtn = document.getElementById('bgt-panel-pause');
|
||
if (pauseBtn) {
|
||
if (task.status === 'running') {
|
||
pauseBtn.textContent = '일시정지';
|
||
pauseBtn.style.display = '';
|
||
} else if (task.status === 'paused' || task.status === 'queued') {
|
||
pauseBtn.textContent = '재개';
|
||
pauseBtn.style.display = '';
|
||
} else {
|
||
pauseBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
const body = document.getElementById('bgt-panel-body');
|
||
if (!body) return;
|
||
|
||
const plan = task.plan || [];
|
||
const journal = task.journal || [];
|
||
const stepsDone = plan.filter(s => s.status === 'done' || s.status === 'skipped').length;
|
||
const lastTool = task.lastToolCall || '—';
|
||
const lastToolAt = task.lastToolCallAt ? new Date(task.lastToolCallAt).toLocaleTimeString() : '—';
|
||
const channel = task.channel === 'telegram' ? 'Telegram' : 'Web UI';
|
||
const startedAt = task.startedAt ? new Date(task.startedAt).toLocaleString() : '—';
|
||
const statusColor = { running:'#0d4faf', queued:'#7c4d00', paused:'#555', stalled:'#9c1a1a', needs_assistance:'#6d2d9e', complete:'#1a6e35', failed:'#9c1a1a' };
|
||
const sc = statusColor[task.status] || '#555';
|
||
|
||
// Build plan steps HTML
|
||
const stepsHTML = plan.map((step, i) => {
|
||
const icon = step.status === 'done' ? '✓' : step.status === 'failed' ? '✕' : step.status === 'skipped' ? '↷' : i === (task.currentStepIndex || 0) && task.status === 'running' ? '▶' : '○';
|
||
const isCurrent = i === (task.currentStepIndex || 0) && task.status !== 'complete';
|
||
return `<div style="display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--line);${isCurrent?'background:#f0f5ff;margin:0 -8px;padding:7px 8px;border-radius:6px;':''}"
|
||
title="${step.notes ? escHtml(step.notes) : ''}">
|
||
<span style="font-size:14px;flex-shrink:0;line-height:1.4">${icon}</span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:12px;font-weight:${isCurrent?'700':'500'};color:${isCurrent?'var(--text)':'var(--muted)'}">${escHtml(step.description)}</div>
|
||
${step.notes ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${escHtml(step.notes.slice(0,120))}</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Build journal HTML (last 30 entries)
|
||
const recentJournal = journal.slice(-30).reverse();
|
||
const journalHTML = recentJournal.map(entry => {
|
||
const time = new Date(entry.t).toLocaleTimeString();
|
||
const typeColor = { tool_call:'#0d4faf', tool_result:'#1a6e35', error:'#9c1a1a', plan_mutation:'#6d2d9e', status_push:'#555', pause:'#7c4d00', resume:'#7c4d00', advisor_decision:'#555', heartbeat:'var(--brand)' };
|
||
const tc = typeColor[entry.type] || '#888';
|
||
return `<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);font-size:11px">
|
||
<span style="color:var(--muted);flex-shrink:0;min-width:52px">${time}</span>
|
||
<span style="color:${tc};flex-shrink:0;font-weight:700;min-width:60px">${entry.type}</span>
|
||
<span style="color:var(--text);flex:1;min-width:0;word-break:break-word">${escHtml(entry.content)}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Build assistance block outside template literal to avoid backtick nesting issues
|
||
let assistanceHTML = '';
|
||
const taskStatusNorm = String(task.status || '').trim().toLowerCase();
|
||
console.log('[BGT panel] task.status =', JSON.stringify(task.status), 'norm =', taskStatusNorm);
|
||
if (taskStatusNorm === 'needs_assistance' || taskStatusNorm === 'paused' || taskStatusNorm === 'stalled' || taskStatusNorm === 'failed') {
|
||
const lastPause = [...journal].reverse().find(e => e.type === 'pause' || e.type === 'error');
|
||
const lastStatusPush = [...journal].reverse().find(e => e.type === 'status_push' && e.content);
|
||
const pauseMsg = escHtml((lastPause?.content || lastStatusPush?.content || 'Task paused and waiting for input.').replace(/^Task paused for assistance:\s*/i, ''));
|
||
const pauseDetail = lastPause?.detail ? escHtml(lastPause.detail.slice(0, 300)) : '';
|
||
const tid = escHtml(task.id);
|
||
assistanceHTML = '<div style="background:#f5eeff;border:1px solid #cba8f5;border-radius:10px;padding:12px 14px;font-size:12px;line-height:1.6;color:#6d2d9e">'
|
||
+ '<div style="font-weight:800;margin-bottom:6px">⚠️ Task needs your input</div>'
|
||
+ '<div style="color:#3d1a6e;margin-bottom:' + (pauseDetail ? '6px' : '0') + '">' + pauseMsg + '</div>'
|
||
+ (pauseDetail ? '<div style="font-size:11px;color:#6d2d9e;opacity:0.8;margin-bottom:4px">' + pauseDetail + '</div>' : '')
|
||
+ '</div>'
|
||
+ '<div style="border:1px solid #cba8f5;border-radius:10px;overflow:hidden;background:#faf5ff;margin-top:8px">'
|
||
+ '<div style="padding:8px 12px;font-size:11px;font-weight:700;color:#6d2d9e;border-bottom:1px solid #e9d8ff">💬 Reply to agent</div>'
|
||
+ '<div style="display:flex;gap:8px;padding:8px">'
|
||
+ '<textarea id="task-reply-input" rows="2" placeholder="Type your reply — the agent will see this and continue..." style="flex:1;resize:none;border:1px solid #cba8f5;border-radius:8px;padding:8px;font-size:12px;font-family:inherit;background:#fff;color:var(--text)"></textarea>'
|
||
+ '<button id="task-reply-send" data-taskid="' + tid + '" style="background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-end">전송</button>'
|
||
+ '</div></div>';
|
||
}
|
||
|
||
body.innerHTML = `
|
||
<!-- Status row -->
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<span style="font-size:11px;background:var(--line);border-radius:999px;padding:2px 10px;font-weight:700;color:${sc}">${STATUS_ICON[task.status]} ${task.status.replace(/_/g,' ')}</span>
|
||
<span style="font-size:11px;color:var(--muted)">${channel}</span>
|
||
<span style="font-size:11px;color:var(--muted)">Started ${startedAt}</span>
|
||
</div>
|
||
|
||
<!-- Summary if complete -->
|
||
${task.finalSummary ? `<div style="background:#efffea;border:1px solid #b2dfb2;border-radius:10px;padding:12px 14px;font-size:12px;line-height:1.6;color:#1a6e35">
|
||
<div style="font-weight:800;margin-bottom:4px">✓ Summary</div>
|
||
${escHtml(task.finalSummary)}
|
||
</div>` : ''}
|
||
|
||
<!-- Needs assistance -->
|
||
${assistanceHTML}
|
||
|
||
<!-- Stats row -->
|
||
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:100px;background:var(--panel-2);border-radius:10px;padding:10px 12px">
|
||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;font-weight:700;margin-bottom:2px">Progress</div>
|
||
<div style="font-size:18px;font-weight:800">${stepsDone}<span style="font-size:12px;color:var(--muted)">/${plan.length}</span></div>
|
||
<div style="font-size:10px;color:var(--muted)">steps done</div>
|
||
</div>
|
||
<div style="flex:1;min-width:100px;background:var(--panel-2);border-radius:10px;padding:10px 12px">
|
||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;font-weight:700;margin-bottom:2px">Last Tool</div>
|
||
<div style="font-size:12px;font-weight:700;word-break:break-all">${escHtml(lastTool.slice(0,30))}</div>
|
||
<div style="font-size:10px;color:var(--muted)">${lastToolAt}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plan steps -->
|
||
${plan.length > 0 ? `
|
||
<div>
|
||
<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:8px">Execution Plan</div>
|
||
<div style="border:1px solid var(--line);border-radius:10px;padding:6px 8px">${stepsHTML}</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Process journal -->
|
||
<div>
|
||
<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:6px">Process Log <span style="font-weight:400;opacity:0.6">(latest first)</span></div>
|
||
<div style="font-family:monospace;font-size:11px;border:1px solid var(--line);border-radius:10px;padding:6px 8px;max-height:300px;overflow-y:auto">
|
||
${journalHTML || '<div style="color:var(--muted);padding:8px">No entries yet.</div>'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete button -->
|
||
${(task.status === 'complete' || task.status === 'failed') ? `
|
||
<button onclick="bgtDeleteTask('${escHtml(task.id)}')" style="border:1px solid #fca5a5;background:#fff0f0;color:#9c1a1a;border-radius:8px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-start">Remove</button>` : ''}
|
||
`;
|
||
|
||
// Wire up the reply send button via JS (avoids inline onclick escaping issues)
|
||
const sendBtn = document.getElementById('task-reply-send');
|
||
if (sendBtn) {
|
||
sendBtn.addEventListener('click', () => bgtSendReply(sendBtn.dataset.taskid));
|
||
}
|
||
// Also allow Enter (without Shift) in the textarea to send
|
||
const replyInput = document.getElementById('task-reply-input');
|
||
if (replyInput) {
|
||
replyInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
bgtSendReply(sendBtn ? sendBtn.dataset.taskid : bgtOpenTaskId);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- Actions -----------------------------------------------------------------
|
||
async function bgtPauseResume() {
|
||
if (!bgtOpenTaskId) return;
|
||
const task = bgtTasks.find(t => t.id === bgtOpenTaskId);
|
||
if (!task) return;
|
||
if (task.status === 'running') {
|
||
// Pause
|
||
try { await api(`/api/bg-tasks/${bgtOpenTaskId}/pause`, { method: 'POST' }); } catch {}
|
||
} else if (task.status === 'paused' || task.status === 'queued') {
|
||
// Resume
|
||
try { await api(`/api/bg-tasks/${bgtOpenTaskId}/resume`, { method: 'POST' }); } catch {}
|
||
}
|
||
await refreshBgTasks();
|
||
await bgtRefreshOpenPanel();
|
||
}
|
||
|
||
async function bgtSendReply(taskId) {
|
||
const input = document.getElementById('task-reply-input');
|
||
if (!input) return;
|
||
const message = input.value.trim();
|
||
if (!message) return;
|
||
input.value = '';
|
||
input.disabled = true;
|
||
try {
|
||
const result = await api(`/api/bg-tasks/${taskId}/message`, { method: 'POST', body: JSON.stringify({ message }) });
|
||
if (result.success) {
|
||
// Refresh the panel after a short delay to show the journal update
|
||
setTimeout(() => bgtRefreshOpenPanel(), 800);
|
||
} else {
|
||
alert('Failed to send: ' + (result.error || 'unknown error'));
|
||
}
|
||
} catch (e) {
|
||
alert('Error sending message: ' + e.message);
|
||
} finally {
|
||
input.disabled = false;
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
async function bgtChatSend() {
|
||
const input = document.getElementById('bgt-chat-input');
|
||
if (!input || !bgtOpenTaskId) return;
|
||
const message = input.value.trim();
|
||
if (!message) return;
|
||
input.value = '';
|
||
input.disabled = true;
|
||
input.style.opacity = '0.5';
|
||
try {
|
||
const result = await api(`/api/bg-tasks/${bgtOpenTaskId}/message`, { method: 'POST', body: JSON.stringify({ message }) });
|
||
if (result.success) {
|
||
// Show optimistic sent indicator in journal area
|
||
const body = document.getElementById('bgt-panel-body');
|
||
if (body) {
|
||
const sentDiv = document.createElement('div');
|
||
sentDiv.style.cssText = 'background:var(--brand);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;align-self:flex-end;max-width:80%;word-break:break-word;opacity:0.85';
|
||
sentDiv.textContent = '\u2192 ' + message;
|
||
body.appendChild(sentDiv);
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
// Refresh panel after short delay to show journal update
|
||
setTimeout(() => bgtRefreshOpenPanel(), 1000);
|
||
} else {
|
||
alert('Failed to send: ' + (result.error || 'unknown error'));
|
||
}
|
||
} catch (e) {
|
||
alert('Error: ' + e.message);
|
||
} finally {
|
||
input.disabled = false;
|
||
input.style.opacity = '';
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
async function bgtDeleteTask(taskId, status) {
|
||
const isActive = status && status !== 'complete' && status !== 'failed';
|
||
const prompt = isActive
|
||
? 'This task is still active. Remove it from the board anyway?'
|
||
: 'Remove this task from the board?';
|
||
if (!confirm(prompt)) return;
|
||
try { await api(`/api/bg-tasks/${taskId}`, { method: 'DELETE' }); } catch {}
|
||
if (bgtOpenTaskId === taskId) closeBgtPanel();
|
||
await refreshBgTasks();
|
||
}
|
||
|
||
// --- Toast notification -------------------------------------------------------
|
||
function bgtToast(title, body) {
|
||
const toast = document.createElement('div');
|
||
toast.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:9999;background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 16px;min-width:240px;max-width:360px;box-shadow:0 4px 24px rgba(0,0,0,0.12);font-family:var(--font)`;
|
||
toast.innerHTML = `<div style="font-size:13px;font-weight:800;margin-bottom:4px">${escHtml(title)}</div>${body ? `<div style="font-size:12px;color:var(--muted);line-height:1.4">${escHtml(body)}</div>` : ''}`;
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 6000);
|
||
}
|
||
// --- Integrations Tab ---------------------------------------------------------
|
||
|
||
let _integTab = 'webhooks';
|
||
|
||
function setIntegTab(tab) {
|
||
_integTab = tab;
|
||
['webhooks', 'mcp'].forEach(t => {
|
||
const btn = document.getElementById('itab-' + t);
|
||
const panel = document.getElementById('itab-panel-' + t);
|
||
if (btn) {
|
||
btn.style.background = t === tab ? '#eaf2ff' : '#fff';
|
||
btn.style.color = t === tab ? '#0d4faf' : 'var(--muted)';
|
||
btn.style.borderColor = t === tab ? '#bdd3f6' : 'var(--line)';
|
||
}
|
||
if (panel) panel.style.display = t === tab ? 'block' : 'none';
|
||
});
|
||
}
|
||
|
||
async function loadIntegrationsTab() {
|
||
setIntegTab(_integTab);
|
||
await loadWebhookSettings();
|
||
await loadMCPServers();
|
||
}
|
||
|
||
// --- Webhooks -----------------------------------------------------------------
|
||
|
||
async function loadWebhookSettings() {
|
||
try {
|
||
const data = await api('/api/settings/hooks');
|
||
const h = data.hooks || {};
|
||
const cb = document.getElementById('wh-enabled');
|
||
const inp = document.getElementById('wh-token');
|
||
const pathInp = document.getElementById('wh-path');
|
||
if (cb) cb.checked = h.enabled === true;
|
||
if (inp) inp.value = h.tokenSet ? '••••••••' : '';
|
||
if (pathInp) pathInp.value = h.path || '/hooks';
|
||
updateWebhookStatus(h.enabled, h.tokenSet);
|
||
updateWebhookUrlDisplay(h.enabled, h.tokenSet, h.path || '/hooks');
|
||
} catch(e) {
|
||
console.warn('loadWebhookSettings:', e);
|
||
}
|
||
}
|
||
|
||
function updateWebhookStatus(enabled, tokenSet) {
|
||
const dot = document.getElementById('wh-status-dot');
|
||
const txt = document.getElementById('wh-status-text');
|
||
if (!dot || !txt) return;
|
||
if (enabled && tokenSet) {
|
||
dot.style.background = '#22c55e';
|
||
txt.textContent = '활성 — 엔드포인트 실행 중';
|
||
txt.style.color = '#166534';
|
||
} else if (!enabled) {
|
||
dot.style.background = '#ccc';
|
||
txt.textContent = '비활성';
|
||
txt.style.color = 'var(--muted)';
|
||
} else {
|
||
dot.style.background = '#f59e0b';
|
||
txt.textContent = '활성화됨 — 토큰 미설정';
|
||
txt.style.color = '#92400e';
|
||
}
|
||
}
|
||
|
||
function updateWebhookUrlDisplay(enabled, tokenSet, hookPath) {
|
||
const el = document.getElementById('wh-url-display');
|
||
if (!el) return;
|
||
if (enabled && tokenSet) {
|
||
const origin = window.location.origin || 'http://localhost:18789';
|
||
el.style.display = 'block';
|
||
el.textContent = origin + hookPath + '/agent';
|
||
} else {
|
||
el.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function generateWebhookToken() {
|
||
const arr = new Uint8Array(24);
|
||
crypto.getRandomValues(arr);
|
||
const token = Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join('');
|
||
const inp = document.getElementById('wh-token');
|
||
if (inp) { inp.value = token; inp.type = 'text'; setTimeout(() => { inp.type = 'password'; }, 4000); }
|
||
}
|
||
|
||
async function saveWebhookSettings() {
|
||
const enabled = document.getElementById('wh-enabled')?.checked ?? false;
|
||
const rawToken = document.getElementById('wh-token')?.value?.trim() || '';
|
||
const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks';
|
||
try {
|
||
const r = await api('/api/settings/hooks', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ enabled, token: rawToken, path: hookPath }),
|
||
});
|
||
if (r.success) {
|
||
await loadWebhookSettings();
|
||
showIntegMsg('? Webhook settings saved — restart gateway to apply', '#166534', '#f0fdf4');
|
||
} else {
|
||
showIntegMsg('Error: ' + (r.error || 'Unknown error'), '#991b1b', '#fef2f2');
|
||
}
|
||
} catch(e) {
|
||
showIntegMsg('Save failed: ' + e.message, '#991b1b', '#fef2f2');
|
||
}
|
||
}
|
||
|
||
async function testWebhookEndpoint() {
|
||
try {
|
||
const r = await api('/api/settings/hooks/test', { method: 'POST' });
|
||
if (r.success) {
|
||
showIntegMsg('? ' + (r.message || 'Endpoint is active'), '#166534', '#f0fdf4');
|
||
} else {
|
||
showIntegMsg('? ' + (r.error || 'Test failed'), '#991b1b', '#fef2f2');
|
||
}
|
||
} catch(e) {
|
||
showIntegMsg('Test failed: ' + e.message, '#991b1b', '#fef2f2');
|
||
}
|
||
}
|
||
|
||
function copyWebhookCurl() {
|
||
const origin = window.location.origin || 'http://localhost:18789';
|
||
const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks';
|
||
const curl = `curl -X POST ${origin}${hookPath}/agent \\
|
||
-H "x-cherryclaw-token: YOUR_TOKEN" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"message": "Hello from webhook", "name": "MyApp", "deliver": true}'`;
|
||
navigator.clipboard?.writeText(curl).then(() => {
|
||
showIntegMsg('cURL example copied to clipboard', '#1e40af', '#eff6ff');
|
||
}).catch(() => {
|
||
showIntegMsg('Could not copy — check browser permissions', '#92400e', '#fffbeb');
|
||
});
|
||
}
|
||
|
||
// --- MCP ---------------------------------------------------------------------
|
||
|
||
let _mcpEditingId = null;
|
||
|
||
const MCP_PRESETS = {
|
||
filesystem: { name: 'Filesystem', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-filesystem\nC:\\Users', env: '', description: 'Read/write local files' },
|
||
github: { name: 'GitHub', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-github', env: 'GITHUB_PERSONAL_ACCESS_TOKEN=', description: 'Repos, PRs, issues' },
|
||
postgres: { name: 'PostgreSQL', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-postgres\npostgresql://localhost/mydb', env: '', description: 'Query PostgreSQL' },
|
||
sqlite: { name: 'SQLite', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-sqlite\n--db-path\nC:\\path\\to\\db.sqlite', env: '', description: 'Local database' },
|
||
brave: { name: 'Brave Search', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-brave-search', env: 'BRAVE_API_KEY=', description: 'Web search' },
|
||
memory: { name: 'Memory', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-memory', env: '', description: 'Persistent knowledge store' },
|
||
};
|
||
|
||
async function loadMCPServers() {
|
||
const el = document.getElementById('mcp-server-list');
|
||
if (!el) return;
|
||
try {
|
||
const data = await api('/api/mcp/servers');
|
||
const servers = data.servers || [];
|
||
if (servers.length === 0) {
|
||
el.innerHTML = '<div style="color:var(--muted);font-style:italic;padding:8px 0">No MCP servers configured yet.<br>Add one above or click a preset on the right.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = servers.map(s => {
|
||
const statusColor = s.status === 'connected' ? '#22c55e' : s.status === 'error' ? '#ef4444' : s.status === 'connecting' ? '#f59e0b' : '#9ca3af';
|
||
const statusLabel = s.status === 'connected' ? `Connected · ${s.toolCount} tool${s.toolCount !== 1 ? 's' : ''}` : s.status === 'error' ? 'Error' : s.status === 'connecting' ? 'Connecting…' : 'Disconnected';
|
||
const toolTip = s.toolNames?.length ? s.toolNames.join(', ') : '';
|
||
return `<div style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;background:${s.enabled ? '#fff' : '#fafafa'}">
|
||
<div style="padding-top:2px">
|
||
<div style="width:8px;height:8px;border-radius:50%;background:${statusColor};flex-shrink:0"></div>
|
||
</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:12px;color:var(--text)">${escHtml(s.name)} <span style="font-weight:400;color:var(--muted);font-size:11px">(${escHtml(s.id)})</span></div>
|
||
<div style="font-size:11px;color:${statusColor};margin-top:1px" title="${escHtml(toolTip)}">${statusLabel}${s.error ? ` — ${escHtml(s.error.slice(0,60))}` : ''}</div>
|
||
${toolTip ? `<div style="font-size:10px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(toolTip)}">${escHtml(toolTip.slice(0,80))}${toolTip.length>80?'…':''}</div>` : ''}
|
||
</div>
|
||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||
${s.status === 'connected'
|
||
? `<button onclick="disconnectMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:#fff;font-size:10px;cursor:pointer">Disconnect</button>`
|
||
: `<button onclick="connectMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--brand);border-radius:5px;background:#fff;color:var(--brand);font-size:10px;font-weight:600;cursor:pointer">Connect</button>`
|
||
}
|
||
<button onclick="editMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:#fff;font-size:10px;cursor:pointer">Edit</button>
|
||
<button onclick="deleteMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid #fecaca;border-radius:5px;background:#fff;color:#ef4444;font-size:10px;cursor:pointer">?</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
el.innerHTML = `<div style="color:#ef4444">Failed to load: ${escHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function showMCPAddForm(editData) {
|
||
_mcpEditingId = editData?.id || null;
|
||
document.getElementById('mcp-add-form').style.display = 'block';
|
||
document.getElementById('mcp-form-title').textContent = _mcpEditingId ? 'MCP 서버 편집' : 'MCP 서버 추가';
|
||
document.getElementById('mcp-form-msg').textContent = '';
|
||
|
||
if (editData) {
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
|
||
v('mcp-f-id', editData.id); v('mcp-f-name', editData.name);
|
||
document.getElementById('mcp-f-transport').value = editData.transport || 'stdio';
|
||
onMCPTransportChange();
|
||
v('mcp-f-command', editData.command || '');
|
||
v('mcp-f-args', (editData.args || []).join('\n'));
|
||
v('mcp-f-env', Object.entries(editData.env || {}).map(([k,v]) => `${k}=${v}`).join('\n'));
|
||
v('mcp-f-url', editData.url || '');
|
||
v('mcp-f-headers', Object.entries(editData.headers || {}).map(([k,v]) => `${k}: ${v}`).join('\n'));
|
||
const cb = document.getElementById('mcp-f-enabled');
|
||
if (cb) cb.checked = editData.enabled !== false;
|
||
} else {
|
||
['mcp-f-id','mcp-f-name','mcp-f-command','mcp-f-args','mcp-f-env','mcp-f-url','mcp-f-headers'].forEach(id => {
|
||
const el = document.getElementById(id); if (el) el.value = '';
|
||
});
|
||
document.getElementById('mcp-f-transport').value = 'stdio';
|
||
onMCPTransportChange();
|
||
const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = true;
|
||
}
|
||
document.getElementById('mcp-f-id').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
|
||
function hideMCPAddForm() {
|
||
document.getElementById('mcp-add-form').style.display = 'none';
|
||
_mcpEditingId = null;
|
||
}
|
||
|
||
function onMCPTransportChange() {
|
||
const t = document.getElementById('mcp-f-transport')?.value;
|
||
document.getElementById('mcp-stdio-fields').style.display = t === 'stdio' ? 'block' : 'none';
|
||
document.getElementById('mcp-sse-fields').style.display = t === 'sse' ? 'block' : 'none';
|
||
}
|
||
|
||
function prefillMCPServer(presetKey) {
|
||
const p = MCP_PRESETS[presetKey];
|
||
if (!p) return;
|
||
showMCPAddForm();
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
|
||
v('mcp-f-id', presetKey);
|
||
v('mcp-f-name', p.name);
|
||
document.getElementById('mcp-f-transport').value = p.transport;
|
||
onMCPTransportChange();
|
||
v('mcp-f-command', p.command || '');
|
||
v('mcp-f-args', p.args || '');
|
||
v('mcp-f-env', p.env || '');
|
||
v('mcp-f-url', p.url || '');
|
||
setIntegTab('mcp');
|
||
}
|
||
|
||
async function saveMCPServer() {
|
||
const id = document.getElementById('mcp-f-id')?.value?.trim();
|
||
const name = document.getElementById('mcp-f-name')?.value?.trim();
|
||
const transport = document.getElementById('mcp-f-transport')?.value || 'stdio';
|
||
const enabled = document.getElementById('mcp-f-enabled')?.checked ?? true;
|
||
const msgEl = document.getElementById('mcp-form-msg');
|
||
|
||
if (!id || !name) { if (msgEl) msgEl.textContent = 'ID와 이름은 필수입니다.'; return; }
|
||
|
||
const cfg = { id, name, transport, enabled };
|
||
|
||
if (transport === 'stdio') {
|
||
cfg.command = document.getElementById('mcp-f-command')?.value?.trim() || '';
|
||
const argsRaw = document.getElementById('mcp-f-args')?.value || '';
|
||
cfg.args = argsRaw.split('\n').map(s => s.trim()).filter(Boolean);
|
||
const envRaw = document.getElementById('mcp-f-env')?.value || '';
|
||
cfg.env = {};
|
||
envRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
|
||
const eq = line.indexOf('='); if (eq > 0) cfg.env[line.slice(0,eq).trim()] = line.slice(eq+1);
|
||
});
|
||
} else {
|
||
cfg.url = document.getElementById('mcp-f-url')?.value?.trim() || '';
|
||
const headersRaw = document.getElementById('mcp-f-headers')?.value || '';
|
||
cfg.headers = {};
|
||
headersRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
|
||
const colon = line.indexOf(':'); if (colon > 0) cfg.headers[line.slice(0,colon).trim()] = line.slice(colon+1).trim();
|
||
});
|
||
}
|
||
|
||
try {
|
||
const r = await api('/api/mcp/servers', { method: 'POST', body: JSON.stringify(cfg) });
|
||
if (r.success) {
|
||
if (msgEl) msgEl.textContent = '? Saved';
|
||
setTimeout(() => hideMCPAddForm(), 600);
|
||
await loadMCPServers();
|
||
// Auto-connect if enabled
|
||
if (enabled) connectMCPServer(id);
|
||
} else {
|
||
if (msgEl) msgEl.textContent = '오류: ' + (r.error || '알 수 없음');
|
||
}
|
||
} catch(e) {
|
||
if (msgEl) msgEl.textContent = '저장 실패: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function editMCPServer(id) {
|
||
try {
|
||
const data = await api('/api/mcp/servers');
|
||
const server = (data.servers || []).find(s => s.id === id);
|
||
if (server) showMCPAddForm(server);
|
||
} catch(e) { console.warn('editMCPServer:', e); }
|
||
}
|
||
|
||
async function deleteMCPServer(id) {
|
||
if (!confirm(`Delete MCP server "${id}"?`)) return;
|
||
try {
|
||
await api(`/api/mcp/servers/${id}`, { method: 'DELETE' });
|
||
await loadMCPServers();
|
||
} catch(e) { showIntegMsg('Delete failed: ' + e.message, '#991b1b', '#fef2f2'); }
|
||
}
|
||
|
||
async function connectMCPServer(id) {
|
||
showIntegMsg('Connecting to ' + id + '…', '#1e40af', '#eff6ff');
|
||
try {
|
||
const r = await api(`/api/mcp/servers/${id}/connect`, { method: 'POST' });
|
||
if (r.success) {
|
||
showIntegMsg(`? Connected — ${(r.tools||[]).length} tool(s) available`, '#166534', '#f0fdf4');
|
||
} else {
|
||
showIntegMsg('? Connection failed: ' + (r.error || 'Unknown'), '#991b1b', '#fef2f2');
|
||
}
|
||
await loadMCPServers();
|
||
} catch(e) {
|
||
showIntegMsg('Connect error: ' + e.message, '#991b1b', '#fef2f2');
|
||
await loadMCPServers();
|
||
}
|
||
}
|
||
|
||
async function disconnectMCPServer(id) {
|
||
try {
|
||
await api(`/api/mcp/servers/${id}/disconnect`, { method: 'POST' });
|
||
await loadMCPServers();
|
||
showIntegMsg('Disconnected from ' + id, '#475569', '#f8fafc');
|
||
} catch(e) { showIntegMsg('Disconnect error: ' + e.message, '#991b1b', '#fef2f2'); }
|
||
}
|
||
|
||
// --- Shared helper ------------------------------------------------------------
|
||
|
||
function showIntegMsg(msg, color, bg) {
|
||
let el = document.getElementById('integ-toast');
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.id = 'integ-toast';
|
||
el.style.cssText = 'position:fixed;bottom:80px;right:24px;z-index:9999;padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,0.12);transition:opacity .3s';
|
||
document.body.appendChild(el);
|
||
}
|
||
el.style.background = bg || '#f0fdf4';
|
||
el.style.color = color || '#166534';
|
||
el.style.border = `1px solid ${color || '#166534'}33`;
|
||
el.style.opacity = '1';
|
||
el.textContent = msg;
|
||
clearTimeout(el._timeout);
|
||
el._timeout = setTimeout(() => { el.style.opacity = '0'; }, 3500);
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// ERROR RESPONSE SYSTEM — Full JavaScript Implementation
|
||
// Handles: show/hide panel, option selection, input rendering,
|
||
// credential entry, form submission, WS event binding
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
// State
|
||
let _errTaskId = null;
|
||
let _errTemplate = null;
|
||
let _errSelectedAction = null;
|
||
let _errCategory = null;
|
||
let _errBackdrop = null;
|
||
|
||
function _getOrCreateErrorBackdrop() {
|
||
if (_errBackdrop) return _errBackdrop;
|
||
_errBackdrop = document.createElement('div');
|
||
_errBackdrop.id = 'error-response-backdrop';
|
||
_errBackdrop.style.cssText = 'position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9997;display:none';
|
||
_errBackdrop.addEventListener('click', closeErrorResponse);
|
||
document.body.appendChild(_errBackdrop);
|
||
return _errBackdrop;
|
||
}
|
||
|
||
function showErrorResponsePanel(taskId, category, errorMessage, errorDetail, template) {
|
||
_errTaskId = taskId;
|
||
_errTemplate = template;
|
||
_errSelectedAction = null;
|
||
_errCategory = category;
|
||
|
||
// Title & description
|
||
const titleEl = document.getElementById('error-response-title');
|
||
const descEl = document.getElementById('error-response-desc');
|
||
if (titleEl) titleEl.textContent = template?.title || ('\u26a0\ufe0f ' + (category || 'Error').toUpperCase());
|
||
if (descEl) descEl.textContent = template?.description || errorMessage || 'The task needs your help to continue.';
|
||
|
||
// Render options
|
||
const optionsEl = document.getElementById('error-response-options');
|
||
if (optionsEl) {
|
||
optionsEl.innerHTML = '';
|
||
const options = template?.options || [
|
||
{ id: 'retry_now', label: '\u27f3 Retry Now', icon: '\u27f3' },
|
||
{ id: 'skip', label: '\u229a Skip Step', icon: '\u229a' },
|
||
{ id: 'cancel', label: '\u2715 Cancel Task', icon: '\u2715', danger: true },
|
||
];
|
||
options.forEach(opt => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'error-option-btn' + (opt.danger ? ' danger' : '');
|
||
btn.dataset.actionId = opt.id;
|
||
btn.innerHTML = `<strong>${escHtml(opt.icon || '')} ${escHtml(opt.label)}</strong>${opt.description ? `<br><span style="font-weight:400;opacity:0.75">${escHtml(opt.description)}</span>` : ''}`;
|
||
btn.addEventListener('click', () => selectErrorOption(opt));
|
||
optionsEl.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
// Clear inputs
|
||
const inputsEl = document.getElementById('error-response-inputs');
|
||
if (inputsEl) {
|
||
inputsEl.innerHTML = '';
|
||
inputsEl.classList.remove('visible');
|
||
}
|
||
|
||
// Reset submit button
|
||
const submitBtn = document.getElementById('error-response-submit-btn');
|
||
if (submitBtn) submitBtn.disabled = true;
|
||
|
||
// Show backdrop + panel
|
||
const backdrop = _getOrCreateErrorBackdrop();
|
||
backdrop.style.display = 'block';
|
||
const panel = document.getElementById('error-response-panel');
|
||
if (panel) panel.classList.add('visible');
|
||
|
||
// Notification toast
|
||
bgtToast('\u26a0\ufe0f Task needs help', (template?.title || category || 'Error') + ' — see panel');
|
||
}
|
||
|
||
function selectErrorOption(opt) {
|
||
_errSelectedAction = opt.id;
|
||
|
||
// Highlight selected
|
||
document.querySelectorAll('.error-option-btn').forEach(b => b.classList.remove('selected'));
|
||
const btn = document.querySelector(`.error-option-btn[data-action-id="${opt.id}"]`);
|
||
if (btn) btn.classList.add('selected');
|
||
|
||
// Show input fields if this option requires them
|
||
const inputsEl = document.getElementById('error-response-inputs');
|
||
if (inputsEl) {
|
||
inputsEl.innerHTML = '';
|
||
inputsEl.classList.remove('visible');
|
||
|
||
const triggerInputs = opt.triggerInputs || [];
|
||
const allFields = _errTemplate?.requiredInputs || [];
|
||
const fieldsToShow = triggerInputs.length > 0
|
||
? allFields.filter(f => triggerInputs.includes(f.id))
|
||
: [];
|
||
|
||
if (fieldsToShow.length > 0) {
|
||
fieldsToShow.forEach(field => {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'input-field';
|
||
const label = document.createElement('label');
|
||
label.textContent = field.label;
|
||
label.setAttribute('for', 'err-input-' + field.id);
|
||
const input = document.createElement('input');
|
||
input.id = 'err-input-' + field.id;
|
||
input.type = field.type || 'text';
|
||
input.placeholder = field.placeholder || '';
|
||
input.dataset.fieldId = field.id;
|
||
if (field.validation === 'digits_only') {
|
||
input.inputMode = 'numeric';
|
||
input.pattern = '[0-9]*';
|
||
input.maxLength = 8;
|
||
}
|
||
wrap.appendChild(label);
|
||
wrap.appendChild(input);
|
||
inputsEl.appendChild(wrap);
|
||
});
|
||
inputsEl.classList.add('visible');
|
||
// Focus first input
|
||
const firstInput = inputsEl.querySelector('input');
|
||
if (firstInput) setTimeout(() => firstInput.focus(), 80);
|
||
}
|
||
}
|
||
|
||
// Enable submit unless it's cancel (which submits immediately)
|
||
const submitBtn = document.getElementById('error-response-submit-btn');
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = opt.danger ? '작업 취소' : '계속';
|
||
}
|
||
}
|
||
|
||
function closeErrorResponse() {
|
||
_errTaskId = null;
|
||
_errTemplate = null;
|
||
_errSelectedAction = null;
|
||
_errCategory = null;
|
||
|
||
const backdrop = document.getElementById('error-response-backdrop');
|
||
if (backdrop) backdrop.style.display = 'none';
|
||
const panel = document.getElementById('error-response-panel');
|
||
if (panel) panel.classList.remove('visible');
|
||
}
|
||
|
||
async function submitErrorResponse() {
|
||
if (!_errTaskId || !_errSelectedAction) return;
|
||
|
||
const action = _errSelectedAction;
|
||
const category = _errCategory;
|
||
|
||
// Collect input values
|
||
const inputs = {};
|
||
document.querySelectorAll('#error-response-inputs input[data-field-id]').forEach(el => {
|
||
if (el.value.trim()) inputs[el.dataset.fieldId] = el.value.trim();
|
||
});
|
||
|
||
// Validate required fields for credential action
|
||
if (action === 'credentials') {
|
||
if (!inputs.email) {
|
||
const emailEl = document.getElementById('err-input-email');
|
||
if (emailEl) { emailEl.style.borderColor = 'var(--err)'; emailEl.focus(); }
|
||
return;
|
||
}
|
||
if (!inputs.password) {
|
||
const pwEl = document.getElementById('err-input-password');
|
||
if (pwEl) { pwEl.style.borderColor = 'var(--err)'; pwEl.focus(); }
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (action === 'verification_code') {
|
||
if (!inputs.code) {
|
||
const codeEl = document.getElementById('err-input-code');
|
||
if (codeEl) { codeEl.style.borderColor = 'var(--err)'; codeEl.focus(); }
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Disable submit to prevent double-send
|
||
const submitBtn = document.getElementById('error-response-submit-btn');
|
||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '전송 중…'; }
|
||
|
||
try {
|
||
const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ action, category, inputs }),
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
closeErrorResponse();
|
||
if (data.resumed) {
|
||
bgtToast('\u2705 Task resuming', 'Agent is continuing with your response');
|
||
// Refresh the kanban board to show updated status
|
||
if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 800);
|
||
} else {
|
||
bgtToast('Task cancelled', 'Task has been stopped');
|
||
if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 400);
|
||
}
|
||
} else {
|
||
bgtToast('\u274c Error', data.error || 'Failed to submit response');
|
||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; }
|
||
}
|
||
} catch (err) {
|
||
console.error('[ErrorResponse] Submit failed:', err);
|
||
bgtToast('\u274c Network error', 'Could not submit response');
|
||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; }
|
||
}
|
||
}
|
||
|
||
// Hook into the existing WebSocket message handler
|
||
// We extend the onmessage handler after the page loads
|
||
(function patchWebSocketForErrorResponse() {
|
||
function applyPatch() {
|
||
// Find the ws variable — it’s declared in a closure, so we intercept via MutationObserver
|
||
// Alternative: override addEventListener on WebSocket prototype to intercept messages
|
||
const _origOnMessage = WebSocket.prototype.__defineGetter__
|
||
? null
|
||
: null; // not needed — use event listener approach below
|
||
|
||
// We hook into the document-level custom event that server-v2’s WS handler
|
||
// already dispatches. If that’s not available, we’ll patch the WS directly.
|
||
document.addEventListener('ws:message', handleErrorResponseWsEvent);
|
||
|
||
// Also directly patch any open WebSocket connections by overriding WebSocket prototype
|
||
const _OrigWS = window.WebSocket;
|
||
window.WebSocket = function(...args) {
|
||
const ws = new _OrigWS(...args);
|
||
const _origAddEventListener = ws.addEventListener.bind(ws);
|
||
ws.addEventListener = function(type, listener, ...rest) {
|
||
if (type === 'message') {
|
||
const wrappedListener = function(e) {
|
||
try {
|
||
const msg = JSON.parse(e.data);
|
||
if (msg.type === 'task_error_requires_response') {
|
||
handleErrorResponseWsEvent({ detail: msg });
|
||
}
|
||
} catch {}
|
||
return listener.call(this, e);
|
||
};
|
||
return _origAddEventListener('message', wrappedListener, ...rest);
|
||
}
|
||
return _origAddEventListener(type, listener, ...rest);
|
||
};
|
||
// Also patch onmessage setter
|
||
let _onmsgHandler = null;
|
||
Object.defineProperty(ws, 'onmessage', {
|
||
get: () => _onmsgHandler,
|
||
set: (fn) => {
|
||
_onmsgHandler = fn;
|
||
_origAddEventListener('message', (e) => {
|
||
try {
|
||
const msg = JSON.parse(e.data);
|
||
if (msg.type === 'task_error_requires_response') {
|
||
handleErrorResponseWsEvent({ detail: msg });
|
||
}
|
||
} catch {}
|
||
if (_onmsgHandler) _onmsgHandler.call(ws, e);
|
||
});
|
||
},
|
||
configurable: true,
|
||
});
|
||
return ws;
|
||
};
|
||
// Copy static properties
|
||
Object.assign(window.WebSocket, _OrigWS);
|
||
window.WebSocket.prototype = _OrigWS.prototype;
|
||
window.WebSocket.CONNECTING = _OrigWS.CONNECTING;
|
||
window.WebSocket.OPEN = _OrigWS.OPEN;
|
||
window.WebSocket.CLOSING = _OrigWS.CLOSING;
|
||
window.WebSocket.CLOSED = _OrigWS.CLOSED;
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', applyPatch);
|
||
} else {
|
||
applyPatch();
|
||
}
|
||
})();
|
||
|
||
// ── Mobile tab navigation ───────────────────────────────────────────────────
|
||
function mobTab(tab) {
|
||
const body = document.body;
|
||
|
||
if (tab === 'menu') {
|
||
const isOpen = body.classList.contains('mob-menu');
|
||
if (isOpen) {
|
||
mobCloseMenu();
|
||
} else {
|
||
body.classList.add('mob-menu');
|
||
}
|
||
// Keep chat/ctx tab highlight unchanged; toggle menu highlight
|
||
document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen);
|
||
return;
|
||
}
|
||
|
||
mobCloseMenu();
|
||
['chat', 'ctx', 'menu'].forEach(t =>
|
||
document.getElementById('mob-tab-' + t)?.classList.toggle('active', t === tab)
|
||
);
|
||
|
||
if (tab === 'ctx') {
|
||
body.classList.add('mob-ctx');
|
||
} else {
|
||
body.classList.remove('mob-ctx');
|
||
}
|
||
}
|
||
|
||
function mobCloseMenu() {
|
||
document.body.classList.remove('mob-menu');
|
||
document.getElementById('mob-backdrop').style.display = 'none';
|
||
document.getElementById('mob-tab-menu')?.classList.remove('active');
|
||
}
|
||
|
||
// ── Right-panel collapse ────────────────────────────────────────────────────
|
||
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
|
||
function applyRightPanelCollapsed(collapsed) {
|
||
document.body.classList.toggle('right-collapsed', !!collapsed);
|
||
// Only override on desktop — narrow viewports use the mobile single-column layout.
|
||
const isDesktop = window.matchMedia('(min-width: 1025px)').matches;
|
||
if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; }
|
||
if (collapsed) {
|
||
document.body.style.gridTemplateColumns = '280px 1fr 36px';
|
||
} else {
|
||
const saved = parseInt(localStorage.getItem('rightPanelWidth'), 10);
|
||
const max = Math.floor(window.innerWidth * 0.65);
|
||
const w = saved && saved >= 280 ? Math.min(saved, max) : '';
|
||
document.body.style.gridTemplateColumns = w ? `280px 1fr ${w}px` : '';
|
||
}
|
||
}
|
||
function toggleRightPanel() {
|
||
const next = !document.body.classList.contains('right-collapsed');
|
||
localStorage.setItem(RIGHT_COLLAPSE_KEY, next ? '1' : '0');
|
||
applyRightPanelCollapsed(next);
|
||
}
|
||
// Default to collapsed when no preference saved.
|
||
(function initRightPanelCollapse() {
|
||
const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
|
||
const collapsed = stored === null ? true : stored === '1';
|
||
applyRightPanelCollapsed(collapsed);
|
||
})();
|
||
|
||
// ── Right-panel resize ──────────────────────────────────────────────────────
|
||
(function initRightPanelResize() {
|
||
const MIN_W = 280;
|
||
const MAX_W_RATIO = 0.65;
|
||
const STORE_KEY = 'rightPanelWidth';
|
||
|
||
function applyWidth(px) {
|
||
document.body.style.gridTemplateColumns = `280px 1fr ${px}px`;
|
||
}
|
||
|
||
function clamp(px) {
|
||
return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px));
|
||
}
|
||
|
||
// Restore saved width (only if not collapsed)
|
||
if (!document.body.classList.contains('right-collapsed')) {
|
||
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
|
||
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
|
||
}
|
||
|
||
function setup() {
|
||
const handle = document.getElementById('right-panel-resize-handle');
|
||
const panel = document.getElementById('right-panel');
|
||
if (!handle || !panel) return;
|
||
|
||
handle.addEventListener('mouseenter', () => { handle.style.background = 'var(--brand, #1668e3)'; handle.style.opacity = '0.35'; });
|
||
handle.addEventListener('mouseleave', () => { handle.style.background = 'transparent'; handle.style.opacity = '1'; });
|
||
|
||
handle.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
const startX = e.clientX;
|
||
const startW = panel.getBoundingClientRect().width;
|
||
|
||
// Prevent iframes from swallowing events during drag
|
||
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
|
||
document.body.style.userSelect = 'none';
|
||
handle.style.background = 'var(--brand, #1668e3)';
|
||
handle.style.opacity = '0.6';
|
||
|
||
function onMove(ev) {
|
||
const newW = clamp(startW + (startX - ev.clientX));
|
||
applyWidth(newW);
|
||
}
|
||
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
|
||
document.body.style.userSelect = '';
|
||
handle.style.background = 'transparent';
|
||
handle.style.opacity = '1';
|
||
const finalW = panel.getBoundingClientRect().width;
|
||
localStorage.setItem(STORE_KEY, Math.round(finalW));
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup);
|
||
else setup();
|
||
})();
|
||
|
||
function handleErrorResponseWsEvent(e) {
|
||
const msg = e.detail || e;
|
||
if (!msg || msg.type !== 'task_error_requires_response') return;
|
||
showErrorResponsePanel(
|
||
msg.taskId,
|
||
msg.errorCategory,
|
||
msg.errorMessage,
|
||
msg.errorDetail,
|
||
msg.template,
|
||
);
|
||
}
|
||
|
||
|
||
// ── Code editor stubs (editor moved to code.html) ────────────────────────
|
||
function v2LiveCodeUpdate() {}
|
||
function codeEditorWriteFile() {}
|
||
|
||
// ── Terminal infrastructure (PTY over WebSocket) ─────────────────────────
|
||
let termWs = null;
|
||
let termWsConnected = false;
|
||
|
||
function termWsConnect() {
|
||
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
let url = `${proto}//${location.host}/ws`;
|
||
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
|
||
if (token) url += `?token=${encodeURIComponent(token)}`;
|
||
try { termWs = new WebSocket(url); } catch { return; }
|
||
termWs.onopen = () => {
|
||
termWsConnected = true;
|
||
for (const [shellId, inst] of xtermInstances) {
|
||
if (!inst.ptyReady) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); }
|
||
}
|
||
};
|
||
termWs.onmessage = (ev) => {
|
||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||
if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write(msg.data); }
|
||
else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); } }
|
||
else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); }
|
||
};
|
||
termWs.onclose = () => {
|
||
termWsConnected = false; termWs = null;
|
||
for (const [,i] of xtermInstances) i.ptyReady = false;
|
||
setTimeout(termWsConnect, 3000);
|
||
};
|
||
termWs.onerror = () => { termWsConnected = false; };
|
||
}
|
||
|
||
function termWsSend(msg) {
|
||
if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; }
|
||
return false;
|
||
}
|
||
|
||
const xtermInstances = new Map();
|
||
|
||
function createXtermShell(container, shellId) {
|
||
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
|
||
const sub = document.createElement('div');
|
||
sub.id = 'xterm-' + id;
|
||
sub.style.cssText = 'position:absolute;inset:0';
|
||
container.appendChild(sub);
|
||
const term = new Terminal({
|
||
theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e',
|
||
selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C',
|
||
yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC',
|
||
brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' },
|
||
fontFamily: "'IBM Plex Mono','Courier New',monospace",
|
||
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
|
||
});
|
||
const fitAddon = new FitAddon.FitAddon();
|
||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
|
||
term.open(sub); fitAddon.fit();
|
||
const inst = { term, fitAddon, ptyReady: false };
|
||
xtermInstances.set(id, inst);
|
||
term.onData(data => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data }); });
|
||
term.onResize(({ cols, rows }) => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); });
|
||
if (termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); }
|
||
return { id, term, fitAddon, inst };
|
||
}
|
||
|
||
// Init WebSocket connection on load
|
||
fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect);
|
||
|
||
// ── Chat terminal (for chat view) ─────────────────────────────────────────
|
||
let chatTermShells = [];
|
||
let activeChatTermShellId = null;
|
||
let chatTermOpen = false;
|
||
let chatTermHeight = 280;
|
||
|
||
function toggleChatTerminal() {
|
||
const panel = document.getElementById('chat-terminal-panel');
|
||
const btn = document.getElementById('chat-term-btn');
|
||
if (!panel) return;
|
||
chatTermOpen = !chatTermOpen;
|
||
if (chatTermOpen) {
|
||
panel.style.display = 'flex';
|
||
if (btn) btn.classList.add('active');
|
||
if (!chatTermShells.length) { addChatTermShell(); }
|
||
else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
|
||
} else {
|
||
panel.style.display = 'none';
|
||
if (btn) btn.classList.remove('active');
|
||
}
|
||
}
|
||
function renderChatShellTabs() {
|
||
const bar = document.getElementById('chat-shell-tabs'); if (!bar) return;
|
||
bar.innerHTML = chatTermShells.map(s => {
|
||
const a = s.id === activeChatTermShellId;
|
||
return `<div class="ct-tab${a?' active':''}" onclick="switchChatTermShell('${s.id}')"><svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg><span>${escHtml(s.name)}</span>${chatTermShells.length>1?`<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">×</span>`:''}</div>`;
|
||
}).join('');
|
||
}
|
||
function addChatTermShell() {
|
||
const id = 'ct_' + Math.random().toString(36).slice(2,8);
|
||
chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
|
||
switchChatTermShell(id);
|
||
}
|
||
function switchChatTermShell(id) {
|
||
const cur = chatTermShells.find(s => s.id === activeChatTermShellId);
|
||
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
|
||
activeChatTermShellId = id;
|
||
const shell = chatTermShells.find(s => s.id === id); if (!shell) return;
|
||
if (shell.term) {
|
||
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
|
||
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
|
||
} else {
|
||
const container = document.getElementById('chat-term-container');
|
||
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
|
||
}
|
||
renderChatShellTabs();
|
||
}
|
||
function closeChatTermShell(id, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
const idx = chatTermShells.findIndex(s => s.id === id);
|
||
if (idx === -1 || chatTermShells.length <= 1) return;
|
||
termWsSend({ type:'pty_kill', shellId:id });
|
||
const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose();
|
||
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
|
||
chatTermShells.splice(idx, 1);
|
||
if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id);
|
||
else renderChatShellTabs();
|
||
}
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return;
|
||
let startY=0, startH=0;
|
||
handle.addEventListener('mousedown', function(e) {
|
||
startY=e.clientY; startH=chatTermHeight;
|
||
const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); };
|
||
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); };
|
||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault();
|
||
});
|
||
});
|
||
|
||
// ── PPTX Workflow Sidebar ─────────────────────────────────────────────────────
|
||
|
||
let _pptxImages = []; // {url, thumb, label}
|
||
let _pptxOutline = null; // parsed pptx-outline JSON
|
||
let _pptxOutlineEdits = {}; // {index: {title, bullets}}
|
||
|
||
function setPptxSbTab(tab) {
|
||
['setup','images','outline'].forEach(t => {
|
||
document.getElementById('psb-tab-' + t).classList.toggle('active', t === tab);
|
||
const el = document.getElementById('psb-' + t);
|
||
if (el) el.style.display = t === tab ? (t === 'outline' ? 'flex' : (t === 'images' ? 'block' : 'flex')) : 'none';
|
||
});
|
||
}
|
||
|
||
function pptxSetStatus(msg) {
|
||
const el = document.getElementById('psb-status');
|
||
if (el) el.textContent = msg;
|
||
}
|
||
|
||
function pptxStartWorkflow() {
|
||
const topic = (document.getElementById('psb-topic').value || '').trim();
|
||
const pmcidsRaw = (document.getElementById('psb-pmcids').value || '').trim();
|
||
const template = document.getElementById('psb-template').value || 'dark';
|
||
const slideCount = parseInt(document.getElementById('psb-slide-count').value) || 0;
|
||
|
||
if (!topic) { pptxSetStatus('주제를 입력하세요.'); return; }
|
||
|
||
const pmcids = pmcidsRaw
|
||
.split(/[\n,]+/)
|
||
.map(s => s.trim().replace(/^PMC/i, 'PMC'))
|
||
.filter(Boolean);
|
||
|
||
_pptxImages = [];
|
||
_pptxOutline = null;
|
||
_pptxOutlineEdits = {};
|
||
pptxRenderImageGrid();
|
||
const cardsEl = document.getElementById('psb-outline-cards');
|
||
if (cardsEl) cardsEl.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 중...</div>';
|
||
const genBtn = document.getElementById('psb-gen-btn');
|
||
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
|
||
|
||
// Build workflow message
|
||
const pmcLine = pmcids.length
|
||
? `\nPMCIDs: ${pmcids.join(', ')}`
|
||
: '';
|
||
const slideLine = slideCount ? `\n슬라이드 수: ${slideCount}장` : '';
|
||
const msg = `[PPTX 워크플로 시작]\n주제: ${topic}${pmcLine}\n템플릿: ${template}${slideLine}\n\n아래 순서로 진행해주세요:\n1. 작업 폴더 생성 (주제 기반)\n2. PDF 다운로드 (pubmed_fulltext, 위 PMCIDs)\n3. 이미지 추출 (pdf_extract_images, 각 PDF)\n4. 소형 이미지 필터링 (python_eval)\n5. 슬라이드 구성을 \`\`\`pptx-outline 블록으로 출력 (JSON 형식)\n6. 확인 대기 (create_presentation은 아직 호출하지 마세요)`;
|
||
|
||
pptxSetStatus('워크플로 시작...');
|
||
setSidebarTab('pptx');
|
||
setPptxSbTab('setup');
|
||
|
||
// Send via main chat
|
||
const input = document.getElementById('chat-input');
|
||
if (input) {
|
||
input.value = msg;
|
||
sendChat();
|
||
}
|
||
}
|
||
|
||
function pptxCaptureImages(toolResultText) {
|
||
// Parse image links from pdf_extract_images output: [label](/api/files/...)
|
||
const re = /\[([^\]]*)\]\((\/api\/files\/[^)]+\.(?:png|jpg|jpeg|gif|webp))\)/gi;
|
||
let m;
|
||
while ((m = re.exec(toolResultText)) !== null) {
|
||
const label = m[1];
|
||
const url = m[2];
|
||
if (!_pptxImages.some(i => i.url === url)) {
|
||
_pptxImages.push({ url, label });
|
||
}
|
||
}
|
||
if (_pptxImages.length) {
|
||
pptxRenderImageGrid();
|
||
const countEl = document.getElementById('psb-img-count');
|
||
if (countEl) countEl.textContent = `(${_pptxImages.length})`;
|
||
// Auto-switch to images tab if PPTX tab is active
|
||
if (sidebarTab === 'pptx') setPptxSbTab('images');
|
||
pptxSetStatus(`이미지 ${_pptxImages.length}개 추출됨`);
|
||
}
|
||
}
|
||
|
||
function pptxRenderImageGrid() {
|
||
const grid = document.getElementById('psb-image-grid');
|
||
if (!grid) return;
|
||
if (!_pptxImages.length) {
|
||
grid.innerHTML = '<div style="grid-column:1/-1;color:var(--muted);font-size:11px;padding:8px;text-align:center">이미지 추출 후 표시됩니다</div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = _pptxImages.map((img, idx) => `
|
||
<div style="position:relative;border:1px solid var(--line);border-radius:6px;overflow:hidden;cursor:pointer;aspect-ratio:4/3;background:var(--panel-2)"
|
||
onclick="pptxImageClick(${idx})" title="${escHtml(img.label)}">
|
||
<img src="${escHtml(img.url)}" style="width:100%;height:100%;object-fit:cover" loading="lazy"
|
||
onerror="this.parentNode.style.background='var(--panel)';this.style.display='none'">
|
||
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.5);color:#fff;font-size:9px;padding:2px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(img.label)}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function pptxImageClick(idx) {
|
||
const img = _pptxImages[idx];
|
||
if (!img) return;
|
||
window.open(img.url, '_blank', 'noopener');
|
||
}
|
||
|
||
function pptxSetOutline(data) {
|
||
_pptxOutline = data;
|
||
_pptxOutlineEdits = {};
|
||
pptxRenderOutlineCards();
|
||
const genBtn = document.getElementById('psb-gen-btn');
|
||
if (genBtn) { genBtn.disabled = false; genBtn.style.opacity = '1'; }
|
||
if (sidebarTab === 'pptx') setPptxSbTab('outline');
|
||
pptxSetStatus(`아웃라인 준비 (${(data.slides || []).length}장)`);
|
||
}
|
||
|
||
const SLIDE_TYPE_ICON = { title:'🏷', section:'📂', content:'📝', image:'🖼', table:'📊', chart:'📈', timeline:'⏳', compare:'⚖' };
|
||
|
||
function pptxRenderOutlineCards() {
|
||
const container = document.getElementById('psb-outline-cards');
|
||
if (!container || !_pptxOutline) return;
|
||
const slides = _pptxOutline.slides || [];
|
||
if (!slides.length) {
|
||
container.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:8px">슬라이드가 없습니다</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = slides.map((s, i) => {
|
||
const icon = SLIDE_TYPE_ICON[s.type] || '📄';
|
||
const imgThumb = s.image_url
|
||
? `<img src="${escHtml(s.image_url)}" style="width:40px;height:30px;object-fit:cover;border-radius:4px;flex-shrink:0" loading="lazy">`
|
||
: `<div style="width:40px;height:30px;border-radius:4px;background:var(--panel);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px">🖼</div>`;
|
||
const bulletsPreview = Array.isArray(s.bullets) && s.bullets.length
|
||
? `<div style="font-size:10px;color:var(--muted);margin-top:3px;overflow:hidden;max-height:32px">${s.bullets.slice(0,2).map(b => `• ${b}`).join('<br>')}</div>`
|
||
: '';
|
||
return `<div class="psb-slide-card" style="border:1px solid var(--line);border-radius:8px;padding:7px 8px;background:var(--panel-2);cursor:pointer" onclick="pptxToggleEdit(${i})">
|
||
<div style="display:flex;align-items:center;gap:7px">
|
||
<span style="font-size:10px;color:var(--muted);flex-shrink:0;width:18px;text-align:right">${i+1}</span>
|
||
${imgThumb}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${icon} ${escHtml(s.title || '(제목 없음)')}</div>
|
||
<div style="font-size:10px;color:var(--muted)">${s.type}</div>
|
||
${bulletsPreview}
|
||
</div>
|
||
</div>
|
||
<div id="psb-edit-${i}" style="display:none;margin-top:7px;border-top:1px solid var(--line);padding-top:7px">
|
||
<input type="text" value="${escHtml(s.title || '')}" oninput="pptxEditTitle(${i}, this.value)"
|
||
style="width:100%;box-sizing:border-box;font-size:12px;padding:4px 6px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;margin-bottom:4px">
|
||
${Array.isArray(s.bullets) ? `<textarea rows="3" oninput="pptxEditBullets(${i}, this.value)"
|
||
style="width:100%;box-sizing:border-box;font-size:11px;padding:4px 6px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;resize:vertical;font-family:'IBM Plex Mono',monospace">${escHtml((s.bullets||[]).join('\n\n'))}</textarea>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function pptxToggleEdit(idx) {
|
||
const el = document.getElementById('psb-edit-' + idx);
|
||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
function pptxEditTitle(idx, val) {
|
||
if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {};
|
||
_pptxOutlineEdits[idx].title = val;
|
||
}
|
||
|
||
function pptxEditBullets(idx, val) {
|
||
if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {};
|
||
_pptxOutlineEdits[idx].bullets = val.split(/\n\n+/).map(g => g.replace(/^\n+|\n+$/g, '')).filter(g => g.trim() !== '');
|
||
}
|
||
|
||
function pptxGenerate() {
|
||
if (!_pptxOutline) return;
|
||
// Apply edits to outline
|
||
const slides = (_pptxOutline.slides || []).map((s, i) => {
|
||
const ed = _pptxOutlineEdits[i];
|
||
if (!ed) return s;
|
||
return Object.assign({}, s, ed.title !== undefined ? { title: ed.title } : {}, ed.bullets !== undefined ? { bullets: ed.bullets } : {});
|
||
});
|
||
const edited = Object.assign({}, _pptxOutline, { slides });
|
||
const hasEdits = Object.keys(_pptxOutlineEdits).length > 0;
|
||
const msg = hasEdits
|
||
? `아웃라인을 수정했습니다. 수정된 아웃라인으로 create_presentation을 호출해주세요:\n\`\`\`json\n${JSON.stringify(edited, null, 2)}\n\`\`\``
|
||
: `아웃라인 확인했습니다. create_presentation으로 슬라이드를 생성해주세요.`;
|
||
|
||
const genBtn = document.getElementById('psb-gen-btn');
|
||
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
|
||
pptxSetStatus('생성 중...');
|
||
|
||
const input = document.getElementById('chat-input');
|
||
if (input) { input.value = msg; sendChat(); }
|
||
}
|
||
|
||
function pptxResetWorkflow() {
|
||
_pptxImages = [];
|
||
_pptxOutline = null;
|
||
_pptxOutlineEdits = {};
|
||
pptxRenderImageGrid();
|
||
const cardsEl = document.getElementById('psb-outline-cards');
|
||
if (cardsEl) cardsEl.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 후 표시됩니다</div>';
|
||
const genBtn = document.getElementById('psb-gen-btn');
|
||
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
|
||
const countEl = document.getElementById('psb-img-count');
|
||
if (countEl) countEl.textContent = '';
|
||
pptxSetStatus('');
|
||
setPptxSbTab('setup');
|
||
}
|
||
|