- Mobile menu: full-screen overlay on bottom nav tap (slide-up animation + close button)
- Sidebar: server log panel with auto-scroll and real-time polling
- Admin: context viewer tab (session usage per user)
- Chat export: Markdown / TXT / JSON
- Online users display in system stats (admin-only)
- Fix: workspace priority (session > global) for multi-user file isolation
- Fix: upload filenames now preserve Unicode/Korean characters
- Fix: log ring buffer wrap-around bug (logTotalCount monotonic counter)
- Security: /api/logs requires auth; online_users restricted to admin
- WebSocket: ping/pong keepalive every 30s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-01 17:20:24 +09:00
co-authored by Claude Sonnet 4.6
parent 6675779566
commit 8da045eb72
3 changed files with 772 additions and 51 deletions
+609 -38
View File
@@ -244,9 +244,70 @@
}
#sidebar-jobs,
#sidebar-skills { flex: 1; min-height: 0; display: flex; flex-direction: column; }
#sidebar-skills { flex: 3; min-height: 0; display: flex; flex-direction: column; }
#sidebar-skills { display: none; }
/* ── Server log panel ─────────────────────────────── */
#sidebar-log-panel {
flex: 2;
min-height: 0;
display: flex;
flex-direction: column;
border-top: 2px solid var(--line);
}
#sidebar-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
background: var(--panel-2, var(--panel));
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
#sidebar-log-header span {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
text-transform: uppercase;
}
#sidebar-log-header button {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 11px;
padding: 1px 4px;
border-radius: 4px;
line-height: 1;
}
#sidebar-log-header button:hover { background: var(--line); color: var(--text); }
#sidebar-log-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 2px;
background: var(--bg, #f7f8fa);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
line-height: 1.5;
}
:root[data-theme="dark"] #sidebar-log-body { background: #0f1117; }
.log-line {
display: flex;
gap: 4px;
padding: 1px 6px;
border-radius: 2px;
overflow: hidden;
}
.log-line:hover { background: var(--line); }
.log-ts { color: var(--muted); flex-shrink: 0; }
.log-msg { color: var(--text); word-break: break-all; white-space: pre-wrap; }
.log-line.warn .log-msg { color: #b45309; }
.log-line.error .log-msg { color: #dc2626; }
:root[data-theme="dark"] .log-line.warn .log-msg { color: #fbbf24; }
:root[data-theme="dark"] .log-line.error .log-msg { color: #f87171; }
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
@@ -937,6 +998,10 @@
.btn { padding: 10px 16px; font-size: 12px; }
.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; }
.export-dropdown { position:relative; display:inline-block; }
.export-menu { display:none; position:absolute; right:0; top:calc(100% + 4px); background:var(--panel-2); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.18); z-index:2000; min-width:160px; overflow:hidden; }
.export-menu button { display:block; width:100%; padding:8px 14px; text-align:left; background:none; border:none; cursor:pointer; font-size:12px; color:var(--text); white-space:nowrap; }
.export-menu button:hover { background:var(--bg3); }
.btn-green { background: var(--ok); }
.btn-red { background: var(--err); }
@@ -1780,16 +1845,28 @@
body.mob-ctx main { display: none; }
body.mob-ctx .right-panel { display: flex; }
/* Sidebar drawer — slides in from left over content */
/* Sidebar — full screen overlay when menu tab is active */
body.mob-menu aside {
display: flex;
position: fixed;
top: 52px; left: 0; bottom: 56px;
width: min(300px, 85vw);
z-index: 500;
box-shadow: 4px 0 24px rgba(0,0,0,0.18);
top: 0; left: 0; right: 0; bottom: 0;
width: 100%;
z-index: 600;
overflow-y: auto;
border-right: 1px solid var(--line);
border: none;
border-radius: 0;
animation: mobMenuSlideUp 0.22s ease;
padding-top: 52px;
}
body.mob-menu aside #mob-menu-close {
display: block !important;
position: fixed;
top: 12px;
right: 12px;
}
@keyframes mobMenuSlideUp {
from { transform: translateY(5%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Hide desktop resize handle on mobile */
@@ -2204,6 +2281,7 @@
<!-- SIDEBAR -->
<aside>
<button id="mob-menu-close" onclick="mobTab('chat')" style="display:none;position:absolute;top:12px;right:12px;z-index:10;border:1px solid var(--line);background:var(--panel-2);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:700;color:var(--muted);cursor:pointer;font-family:'Manrope',sans-serif">✕ 닫기</button>
<div id="sidebar-tabs">
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">🧩 스킬</button>
@@ -2233,6 +2311,18 @@
<div class="empty-state" style="padding:16px">스킬 로딩 중...</div>
</div>
</div>
<!-- SERVER LOG PANEL -->
<div id="sidebar-log-panel">
<div id="sidebar-log-header">
<span>서버 로그</span>
<div style="display:flex;gap:4px;align-items:center">
<button id="log-scroll-lock-btn" onclick="toggleLogScrollLock()" title="자동 스크롤 고정">↓</button>
<button onclick="clearLogPanel()" title="지우기">✕</button>
</div>
</div>
<div id="sidebar-log-body"></div>
</div>
</aside>
<!-- MAIN -->
@@ -2550,6 +2640,10 @@
<div class="sys-v" id="sys-ollama">확인 중...</div>
<div class="sys-sub" id="sys-runtime-sub">Gateway RSS -- MB • Updated --</div>
</div>
<div class="sys-card full" onclick="openSettings('context-viewer')" title="컨텍스트 뷰어 열기" style="cursor:pointer">
<div class="sys-k">접속자</div>
<div class="sys-v" id="sys-online-users" style="font-size:12px;font-weight:600;letter-spacing:0">-</div>
</div>
</div>
</div>
<div class="right-section" id="agent-context-section">
@@ -2625,6 +2719,14 @@
<div style="display:flex;gap:6px">
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="clearChat()">✕ Chat</button>
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="newChatSession()">+</button>
<div class="export-dropdown">
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="toggleExportMenu(event)" title="채팅 내보내기">↓ 내보내기</button>
<div class="export-menu" id="export-menu">
<button onclick="exportChat('md')">📄 Markdown (.md)</button>
<button onclick="exportChat('txt')">📝 텍스트 (.txt)</button>
<button onclick="exportChat('json')">{ } JSON (.json)</button>
</div>
</div>
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="openSettings()">설정</button>
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="setSidebarTab('skills')">🧩</button>
</div>
@@ -2751,6 +2853,7 @@
<button id="settings-tab-channels" class="sidebar-action" onclick="setSettingsTab('channels')">채널</button>
<button id="settings-tab-ppt" class="sidebar-action" onclick="setSettingsTab('ppt')">프레젠테이션</button>
<button id="settings-tab-integrations" class="sidebar-action" onclick="setSettingsTab('integrations')">연동</button>
<button id="settings-tab-context-viewer" class="sidebar-action" onclick="setSettingsTab('context-viewer')">컨텍스트 뷰어</button>
</div>
<div id="settings-panel-system" style="padding:14px 16px;display:grid;grid-template-columns:1fr 1fr;gap:14px">
@@ -3654,6 +3757,17 @@
</div>
<div id="settings-panel-context-viewer" style="display:none;padding:14px 16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div class="right-section-title">사용자 컨텍스트 뷰어</div>
<button class="btn btn-sm" style="background:var(--panel-2);border:1px solid var(--line);color:var(--muted)" onclick="loadContextViewerTab()">새로고침</button>
</div>
<div id="cv-warn-banner" style="display:none;padding:8px 12px;background:#fff7ed;border:1px solid #fbbf24;border-radius:8px;font-size:12px;color:#92400e;margin-bottom:12px"></div>
<div id="cv-sessions-list" style="display:flex;flex-direction:column;gap:10px">
<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line)">
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSettings()">취소</button>
<button class="btn btn-sm" onclick="saveSettings()">저장</button>
@@ -3693,6 +3807,62 @@ let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication on page load
// ── Server log panel ──────────────────────────────────────────────────────────
let logScrollLocked = true;
let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size
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;
}
function renderLogEntries(entries) {
const body = document.getElementById('sidebar-log-body');
if (!body) return;
const frag = document.createDocumentFragment();
for (const e of entries) {
const line = document.createElement('div');
line.className = 'log-line ' + (e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : '');
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;
}
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 = [];
@@ -5343,6 +5513,87 @@ function clearChat() {
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) {
@@ -5664,7 +5915,7 @@ function setQuickThinkingEffort(level) {
function setSettingsTab(tab) {
settingsTab = tab;
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt'];
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt', 'context-viewer'];
tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`);
@@ -5683,6 +5934,7 @@ function setSettingsTab(tab) {
if (t === 'credentials') loadCredentialsTab();
if (t === 'ppt') loadPptTab();
if (t === 'users') loadUsersTab();
if (t === 'context-viewer') loadContextViewerTab();
} else {
panel.style.display = 'none';
}
@@ -6290,6 +6542,81 @@ async function removeUser(username) {
} catch (e) { alert(e.message || '삭제 실패'); }
}
// --- Context Viewer Tab -------------------------------------------------------
let cvAutoRefreshTimer = null;
async function loadContextViewerTab() {
const listEl = document.getElementById('cv-sessions-list');
const warnEl = document.getElementById('cv-warn-banner');
if (!listEl) return;
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>';
if (warnEl) warnEl.style.display = 'none';
try {
const data = await api('/api/admin/context-viewer');
if (!data || !data.sessions) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--err)">권한이 없거나 데이터를 불러올 수 없습니다.</div>';
return;
}
const sessions = data.sessions;
if (sessions.length === 0) {
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">세션이 없습니다.</div>';
return;
}
// Token warning banner
const warnSessions = sessions.filter(s => s.usagePct >= 70);
if (warnEl && warnSessions.length > 0) {
warnEl.style.display = 'block';
warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => {
const color = s.usagePct >= 90 ? '#dc2626' : '#d97706';
return `<strong style="color:${color}">${s.username}</strong> (${s.id.slice(0,8)}…) ${s.usagePct}%`;
}).join(' &nbsp;|&nbsp; ');
}
listEl.innerHTML = sessions.map(s => {
const pct = s.usagePct;
const barColor = pct >= 90 ? 'var(--err)' : pct >= 70 ? 'var(--warn)' : 'var(--ok)';
const lastActive = s.lastActiveAt ? new Date(s.lastActiveAt).toLocaleString() : '-';
const onlineDot = s.online
? '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px"></span>'
: '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--line-strong);margin-right:4px"></span>';
return `
<div 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">${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:10px">
<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>` : ''}
</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>`;
}
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// --- PPT Template & Skin Tab ---------------------------------------------------
var pptTemplates = [];
@@ -7196,6 +7523,20 @@ async function loadSystemStats() {
'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', '--%');
@@ -7627,13 +7968,29 @@ function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _normalizeFileUrl(url) {
// Convert sandbox:/mnt/... or /mnt/user-data/... paths to /api/files/... URLs
if (/^sandbox:/i.test(url)) {
var p = url.replace(/^sandbox:\/*/i, '');
var idx = p.indexOf('uploads/');
return '/api/files/' + (idx !== -1 ? p.slice(idx) : 'uploads/' + p.split('/').pop());
}
if (/^\/mnt\/(user-data|data)\//i.test(url)) {
var idx = url.indexOf('uploads/');
return '/api/files/' + (idx !== -1 ? url.slice(idx) : 'uploads/' + url.split('/').pop());
}
return url;
}
function _renderLinkButton(text, url) {
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">'
@@ -7657,6 +8014,31 @@ function _renderLinkButton(text, url) {
+ '</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 + '" onclick="toggleXlsxEmbedBtn(this)">&#9664; 미리보기</button>'
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>'
+ '</div>'
+ '<div class="xlsx-preview-body" style="display:none;max-height:340px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:10px"></div>'
+ '</div>';
}
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
if (isTxt) {
var txtLabel = escHtml(text.replace(/^📝\s*/, ''));
return '<div class="pdf-file-row txt-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<span style="font-size:14px">📝</span>'
+ '<span style="font-weight:600;color:var(--text)">' + txtLabel + '</span>'
+ '<button class="pptx-preview-btn" data-txt-url="' + escHtml(url) + '" onclick="toggleTxtEmbedBtn(this)">&#9664; 미리보기</button>'
+ '<a href="' + escHtml(url) + '" download="' + txtLabel + '" class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>'
+ '</div>'
+ '<div class="txt-preview-body" style="display:none;max-height:420px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;word-break:break-word;line-height:1.7;color:var(--text);font-family:inherit"></div>'
+ '</div>';
}
if (isDownload) {
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>';
@@ -7678,6 +8060,91 @@ function togglePdfEmbed(url, containerId, btn) {
}
}
function toggleXlsxEmbedBtn(btn) {
var card = btn.closest('.xlsx-card');
if (!card) return;
var container = card.querySelector('.xlsx-preview-body');
if (!container) return;
var url = encodeURI(btn.getAttribute('data-xlsx-url'));
if (container.style.display === 'none') {
container.style.display = 'block';
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.innerHTML = '불러오는 중…';
fetch(url, { credentials: 'same-origin' })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
})
.then(function(buf) {
loadSheetJS(function() {
try {
var wb = XLSX.read(new Uint8Array(buf), { type: 'array' });
var html = '';
wb.SheetNames.forEach(function(sn) {
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
html += '<div style="font-weight:700;font-size:11px;margin-bottom:6px">' + escHtml(sn) + '</div>';
html += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
rows.slice(0, 50).forEach(function(row, ri) {
html += '<tr>';
row.forEach(function(cell) {
var tag = ri === 0 ? 'th' : 'td';
html += '<' + tag + ' style="border:1px solid var(--line);padding:3px 8px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '</' + tag + '>';
});
html += '</tr>';
});
if (rows.length > 50) html += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 50) + '행 더</td></tr>';
html += '</table></div><br>';
});
container.innerHTML = html;
container.dataset.loaded = '1';
} catch(e) { container.textContent = 'Excel 파싱 오류: ' + e.message; }
});
})
.catch(function(e) { container.textContent = '불러오기 실패: ' + e.message; });
}
} else {
container.style.display = 'none';
btn.innerHTML = '&#9664; 미리보기';
}
}
function toggleStagedExcelPreview(id) {
var el = document.getElementById('excel-staged-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function toggleTxtEmbedBtn(btn) {
var card = btn.closest('.txt-card');
if (!card) return;
var container = card.querySelector('.txt-preview-body');
if (!container) return;
var url = encodeURI(btn.getAttribute('data-txt-url'));
if (container.style.display === 'none') {
container.style.display = 'block';
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.textContent = '불러오는 중…';
fetch(url, { credentials: 'same-origin' })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function(t) {
container.textContent = t;
container.dataset.loaded = '1';
})
.catch(function(e) {
container.textContent = '텍스트를 불러올 수 없습니다. (' + e.message + ')';
});
}
} else {
container.style.display = 'none';
btn.innerHTML = '&#9664; 미리보기';
}
}
function renderMarkdown(text) {
if (!text) return '';
@@ -7904,22 +8371,43 @@ function handleFileUpload(input) {
// 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', credentials: 'same-origin', 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 result = '**[파일: ' + file.name + ']**\n\n';
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 });
result += '### ' + sn + '\n';
rows.forEach(function(row) { result += '| ' + row.join(' | ') + ' |\n'; });
result += '\n';
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 });
});
stageAttachment(file.name, null, result, 'excel');
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
};
r3.readAsArrayBuffer(file);
r3.readAsArrayBuffer(xlsxFile);
});
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
@@ -7939,9 +8427,9 @@ function handleFileUpload(input) {
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) {
return Promise.all(ps.map(function(p, pi) {
return p.getTextContent().then(function(tc) {
return tc.items.map(function(it) { return it.str; }).join(' ');
return pdfPageToText(tc.items, pi + 1, pdf.numPages);
});
}));
}).then(function(texts) {
@@ -7956,31 +8444,36 @@ function handleFileUpload(input) {
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 trimmed = (extracted.text || '').trim().slice(0, 8000);
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n';
if (trimmed) {
content += '**[PDF 내용 — ' + extracted.pages + '페이지]**\n\n' + trimmed;
if ((extracted.text || '').trim().length > 8000) content += '\n\n*(앞 8000자만 포함)*';
var fullText = (extracted.text || '').trim();
// PDF 텍스트를 .txt 파일로 자동 저장 → PDF 카드에 txt 버튼 추가
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' }));
// PDF 카드 먼저 추가 (txt 저장 대기 없이)
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.path) {
var txtUrl = '/api/files/' + d.path;
addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
// 이미 스테이징된 PDF 카드에 txt 버튼 주입
addTxtButtonToPdfCard(pdfFile.name, txtName, txtUrl);
}
}).catch(function() {});
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 {
content += '*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
}
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
@@ -8010,9 +8503,11 @@ function insertIntoChatInput(text) {
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
var stagedAttachments = [];
function stageAttachment(name, url, content, type) {
function stageAttachment(name, url, content, type, extra) {
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
stagedAttachments.push({ id: id, name: name, url: url, content: content, type: type });
var att = { id: id, name: name, url: url, content: content, type: type };
if (extra) Object.assign(att, extra);
stagedAttachments.push(att);
renderStagedAttachments();
}
@@ -8037,13 +8532,19 @@ function renderStagedAttachments() {
// 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">' + escHtml(a.name) + '</span>'
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
+ '<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>';
}
@@ -8063,7 +8564,21 @@ function renderStagedAttachments() {
+ '</div>';
}
// 기타 카드 (text, csv, excel)
// 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>'
@@ -8093,6 +8608,12 @@ function toggleStagedTxtPreview(id) {
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 찾기
@@ -8118,6 +8639,57 @@ function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
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 동적 로드 ──────────────────────────────────────────────
var _sheetJsLoaded = false;
function loadSheetJS(cb) {
@@ -10049,7 +10621,6 @@ function mobTab(tab) {
mobCloseMenu();
} else {
body.classList.add('mob-menu');
document.getElementById('mob-backdrop').style.display = 'block';
}
// Keep chat/ctx tab highlight unchanged; toggle menu highlight
document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen);