From 8da045eb72868039797a88df5b06020edefb2b90 Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 1 May 2026 17:20:24 +0900 Subject: [PATCH] v2.2.3 - 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 --- src/gateway/server-v2.ts | 104 ++++++- src/gateway/session.ts | 72 +++++ web-ui/index.html | 647 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 772 insertions(+), 51 deletions(-) diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 75df99f..9ed695c 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -27,7 +27,7 @@ import { import { getVault, SecretValue } from '../security/vault'; import { getOllamaClient } from '../agents/ollama-client'; import { spawnAgent } from '../agents/spawner'; -import { getSession, addMessage, getHistory, getHistoryForApiCall, getWorkspace, setWorkspace, clearHistory, cleanupSessions, migrateGlobalSessionsToUser } from './session'; +import { getSession, addMessage, getHistory, getHistoryForApiCall, getWorkspace, setWorkspace, clearHistory, cleanupSessions, migrateGlobalSessionsToUser, listAllSessions } from './session'; import { hookBus } from './hooks'; import { loadWorkspaceHooks } from './hook-loader'; import { runBootMd } from './boot'; @@ -154,6 +154,27 @@ import { } from './agent-builder-integration'; +// ─── Server log ring buffer ──────────────────────────────────────────────────── +const LOG_RING_SIZE = 300; +interface LogEntry { ts: string; level: 'log' | 'warn' | 'error'; msg: string } +const logRing: LogEntry[] = []; +let logTotalCount = 0; // monotonic counter — never wraps with the ring +function pushLog(level: LogEntry['level'], args: unknown[]) { + const msg = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); + const now = new Date(); + const ts = now.toTimeString().slice(0, 8); // HH:MM:SS + logRing.push({ ts, level, msg }); + logTotalCount++; + if (logRing.length > LOG_RING_SIZE) logRing.shift(); +} +const _cLog = console.log.bind(console); +const _cWarn = console.warn.bind(console); +const _cErr = console.error.bind(console); +console.log = (...a) => { _cLog(...a); pushLog('log', a); }; +console.warn = (...a) => { _cWarn(...a); pushLog('warn', a); }; +console.error = (...a) => { _cErr(...a); pushLog('error', a); }; +// ────────────────────────────────────────────────────────────────────────────── + const config = getConfig().getConfig(); const CONFIG_DIR_PATH = getConfig().getConfigDir(); const PORT = config.gateway.port || (process.env.GATEWAY_PORT ? parseInt(process.env.GATEWAY_PORT, 10) : 18789); @@ -934,7 +955,7 @@ const TOOL_BLOCKS: Record = { desktop: `DESKTOP TOOLS: desktop_screenshot() → capture+OCR. desktop_find_window(name) → find window. desktop_focus_window(name) → bring to front (use SHORT process name: msedge, chrome, code). desktop_click(x,y,button) → click coords (button: "left" or "right"). desktop_type(text) → type. desktop_press_key(key) → key combo. desktop_drag(x1,y1,x2,y2) → drag. desktop_get_clipboard()/set_clipboard(text). Always screenshot first. Focus window before click/type. Fail twice on focus → stop and report. IMAGE DOWNLOAD: prefer shell("curl -o ") or shell("python -c ...urllib...") for downloading images. Only use desktop right-click (desktop_click(x,y,"right") → screenshot → click "Save as") as a last resort when shell download fails due to auth or hotlink protection.`, - files: `FILE TOOLS: read_file(filename) → contents with line numbers. Always read before editing. replace_lines(filename,start,end,content) → surgical edit. insert_after(filename,line,content) → insert. delete_lines(filename,start,end) → delete. find_replace(filename,find,replace) → exact text swap. create_file(filename,content) → new only (fails if exists). delete_file(filename). RULES: (1) ALWAYS call list_files first to see what already exists before creating any file. (2) If a file already exists, read it first and use replace_lines/edit instead of create_file. (3) Never recreate a file that already exists — read and edit it. (4) read first, surgical edits only, never rewrite whole file for partial changes.`, + files: `FILE TOOLS: read_file(filename) → contents with line numbers. Always read before editing. replace_lines(filename,start,end,content) → surgical edit. insert_after(filename,line,content) → insert. delete_lines(filename,start,end) → delete. find_replace(filename,find,replace) → exact text swap. create_file(filename,content) → new only (fails if exists). delete_file(filename). RULES: (1) ALWAYS call list_files first to see what already exists before creating any file. (2) If a file already exists, read it first and use replace_lines/edit instead of create_file. (3) Never recreate a file that already exists — read and edit it. (4) read first, surgical edits only, never rewrite whole file for partial changes. FILE LINKS IN RESPONSES: ALWAYS use /api/files/uploads/filename format for file links and images. NEVER use sandbox:, file://, /mnt/data/, /mnt/user-data/, or any absolute filesystem path — these URLs are invalid and will fail for the user.`, task: `TASK TOOLS: task_control(action,...) actions: list/get/resume/rerun/pause/delete. start_task(title,prompt) → launch new background task. Check for existing tasks first before creating — never duplicate. Do NOT use read_file to check task state.`, @@ -3472,7 +3493,9 @@ async function handleChat( const bootAllowedTools = new Set(['list_files', 'read_file']); const configuredWorkspace = getConfig().getWorkspacePath(); const sessionWorkspace = getWorkspace(sessionId); - const workspacePath = configuredWorkspace || sessionWorkspace; + // Session workspace (user-specific) takes priority over global configured workspace + // so that multi-user file tools (read_file, etc.) resolve to the correct user directory. + const workspacePath = sessionWorkspace || configuredWorkspace; if (workspacePath && sessionWorkspace !== workspacePath) { setWorkspace(sessionId, workspacePath); } @@ -7042,6 +7065,18 @@ app.delete('/api/users/:username', (req, res) => { res.json({ success: true }); }); +app.get('/api/admin/context-viewer', (req, res) => { + const session = getSessionUser(req); + if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' }); + const sessions = listAllSessions(); + const activeTokens = [...activeSessions.values()].map(s => s.username); + const withOnline = sessions.map(s => ({ + ...s, + online: activeTokens.includes(s.username), + })); + res.json({ sessions: withOnline }); +}); + app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } })); @@ -7224,6 +7259,23 @@ app.get('/api/pptx/preview', async (req: express.Request, res: express.Response) } }); +/** + * Sanitize an uploaded filename while preserving the original name (including Unicode/Korean). + * - Strips path separators, null bytes, and characters forbidden on Windows/Linux + * - Prevents hidden files (leading dot) + * - Appends a short suffix only when a file with that name already exists + */ +function resolveUploadName(uploadsDir: string, originalName: string): string { + const base = path.basename(String(originalName || 'upload')); + // Remove control chars, path separators, and chars illegal on Windows (:*?"<>|) + const safe = base.replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, '_').replace(/^\.+/, '_').trim() || 'upload'; + if (!fs.existsSync(path.join(uploadsDir, safe))) return safe; + // Collision: insert a short timestamp before the extension + const ext = path.extname(safe); + const stem = path.basename(safe, ext); + return `${stem}_${Date.now()}${ext}`; +} + app.post('/api/upload/image', (req: express.Request, res: express.Response) => { const contentType = String(req.headers['content-type'] || ''); if (!contentType.includes('multipart/form-data')) { @@ -7270,11 +7322,10 @@ app.post('/api/upload/image', (req: express.Request, res: express.Response) => { const workspacePath = uploadUser?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd()); const uploadsDir = path.join(workspacePath, 'uploads'); fs.mkdirSync(uploadsDir, { recursive: true }); - const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase(); - const uniqueName = `${Date.now()}-${safeName}`; - const filePath = path.join(uploadsDir, uniqueName); + const finalName = resolveUploadName(uploadsDir, filename); + const filePath = path.join(uploadsDir, finalName); fs.writeFileSync(filePath, fileData); - const relativePath = `uploads/${uniqueName}`; + const relativePath = `uploads/${finalName}`; res.json({ success: true, path: relativePath, url: `/api/files/${relativePath}`, size: fileData.length, type: filetype }); }); req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); }); @@ -7327,12 +7378,11 @@ app.post('/api/upload/file', (req: express.Request, res: express.Response) => { const workspacePath = uploadUser?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd()); const uploadsDir = path.join(workspacePath, 'uploads'); fs.mkdirSync(uploadsDir, { recursive: true }); - const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase(); - const uniqueName = `${Date.now()}-${safeName}`; - const filePath = path.join(uploadsDir, uniqueName); + const finalName = resolveUploadName(uploadsDir, filename); + const filePath = path.join(uploadsDir, finalName); fs.writeFileSync(filePath, fileData); - const relativePath = `uploads/${uniqueName}`; - res.json({ success: true, path: relativePath, url: `/api/files/${relativePath}`, filename, size: fileData.length, type: filetype }); + const relativePath = `uploads/${finalName}`; + res.json({ success: true, path: relativePath, url: `/api/files/${relativePath}`, filename: finalName, size: fileData.length, type: filetype }); }); req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); }); }); @@ -9234,7 +9284,16 @@ function getCpuPercent(): number { return Math.round(100 * (1 - idleDiff / totalDiff)); } -app.get('/api/system-stats', async (_req, res) => { +app.get('/api/logs', (req, res) => { + const authCfg = getAuthConfig(); + if (authCfg.enabled) { + const sess = getSessionUser(req); + if (!sess) return res.status(401).json({ error: 'Unauthorized' }); + } + res.json({ entries: logRing.slice(), totalCount: logTotalCount }); +}); + +app.get('/api/system-stats', async (req, res) => { const totalMem = osModule.totalmem(); const freeMem = osModule.freemem(); const usedMem = totalMem - freeMem; @@ -9290,6 +9349,12 @@ app.get('/api/system-stats', async (_req, res) => { gpuStats = { available: true, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: gpuInfo.name ?? 'Apple Silicon' }; } + const callerSession = getSessionUser(req); + const isAdmin = callerSession?.role === 'admin'; + const onlineUsers = isAdmin + ? [...new Set([...activeSessions.values()].map(s => s.username))] + : undefined; + res.json({ system: { cpu_percent: cpuPercent, @@ -9302,6 +9367,7 @@ app.get('/api/system-stats', async (_req, res) => { gateway_process: { rss_mb: rss / (1024 * 1024) }, active_provider: (getConfig().getConfig() as any).llm?.provider || 'ollama', active_model: (() => { const c = getConfig().getConfig() as any; const p = c.llm?.provider || 'ollama'; return c.llm?.providers?.[p]?.model || c.models?.primary || 'unknown'; })(), + ...(onlineUsers !== undefined ? { online_users: onlineUsers } : {}), timestamp: new Date().toISOString(), }); }); @@ -9866,11 +9932,23 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { const session = authEnabled && token ? activeSessions.get(token) : undefined; const user = authEnabled ? (session ? { username: session.username, role: session.role, workspace: getUserWorkspace(session.username) } : null) : null; (ws as any).user = user; + (ws as any).isAlive = true; + ws.on('pong', () => { (ws as any).isAlive = true; }); console.log(`[v2] WS connected${user ? ` (user: ${user.username})` : ''}`); ws.on('message', (d) => { try { JSON.parse(d.toString()); } catch {} }); ws.on('close', () => console.log(`[v2] WS disconnected${user ? ` (user: ${user.username})` : ''}`)); }); +// Ping all clients every 30s to prevent proxy/OS idle-timeout (92s pattern) +const wsPingInterval = setInterval(() => { + if (!wss) return; + wss.clients.forEach((ws: any) => { + if (ws.isAlive === false) { ws.terminate(); return; } + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + server.on('error', (err: any) => { if (err?.code === 'EADDRINUSE') { console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`); diff --git a/src/gateway/session.ts b/src/gateway/session.ts index f57cd22..8f05a65 100644 --- a/src/gateway/session.ts +++ b/src/gateway/session.ts @@ -541,6 +541,78 @@ export function setWorkspace(id: string, workspacePath: string): void { * * Returns a summary of what was copied/skipped. */ +export interface SessionSummary { + id: string; + username: string; + historyCount: number; + lastActiveAt: number; + createdAt: number; + contextTokenEstimate: number; + numCtx: number; + usagePct: number; + lastUserMessage: string; + lastAssistantMessage: string; +} + +export function listAllSessions(): SessionSummary[] { + const numCtx = resolveNumCtx(); + const results: SessionSummary[] = []; + + const dirsToScan: { dir: string; username: string }[] = []; + try { + const usersRoot = path.join(getConfig().getConfigDir(), 'users'); + if (fs.existsSync(usersRoot)) { + for (const entry of fs.readdirSync(usersRoot, { withFileTypes: true })) { + if (entry.isDirectory()) { + const userSessionDir = path.join(usersRoot, entry.name, 'sessions'); + if (fs.existsSync(userSessionDir)) { + dirsToScan.push({ dir: userSessionDir, username: entry.name }); + } + } + } + } + } catch { /* skip */ } + + const globalDir = getSessionDir(); + if (fs.existsSync(globalDir)) { + dirsToScan.push({ dir: globalDir, username: 'legacy' }); + } + + for (const { dir, username } of dirsToScan) { + let files: string[]; + try { files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); } + catch { continue; } + + for (const file of files) { + if (file.startsWith('.')) continue; + const id = file.replace(/\.json$/i, ''); + try { + const raw = fs.readFileSync(path.join(dir, file), 'utf-8'); + const data: Session = JSON.parse(raw); + const hist = data.history || []; + const tokens = data.contextTokenEstimate ?? 0; + const lastUser = [...hist].reverse().find(m => m.role === 'user'); + const lastAsst = [...hist].reverse().find(m => m.role === 'assistant'); + results.push({ + id, + username: data.username || username, + historyCount: hist.length, + lastActiveAt: data.lastActiveAt || 0, + createdAt: data.createdAt || 0, + contextTokenEstimate: tokens, + numCtx, + usagePct: numCtx > 0 ? Math.round((tokens / numCtx) * 100) : 0, + lastUserMessage: String(lastUser?.content || '').slice(0, 120), + lastAssistantMessage: String(lastAsst?.content || '').slice(0, 120), + }); + } catch { /* skip unreadable */ } + } + } + + results.sort((a, b) => b.lastActiveAt - a.lastActiveAt); + return results; +} + export function migrateGlobalSessionsToUser(username: string): { copied: number; skipped: number; diff --git a/web-ui/index.html b/web-ui/index.html index 32c93f8..92f4b54 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -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 @@ @@ -2550,6 +2640,10 @@
확인 중...
Gateway RSS -- MB • Updated --
+
+
접속자
+
-
+
@@ -2625,6 +2719,14 @@
+
+ +
+ + + +
+
@@ -2751,6 +2853,7 @@ +
@@ -3654,6 +3757,17 @@
+ +
@@ -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 = '
로딩 중...
'; + if (warnEl) warnEl.style.display = 'none'; + + try { + const data = await api('/api/admin/context-viewer'); + if (!data || !data.sessions) { + listEl.innerHTML = '
권한이 없거나 데이터를 불러올 수 없습니다.
'; + return; + } + const sessions = data.sessions; + if (sessions.length === 0) { + listEl.innerHTML = '
세션이 없습니다.
'; + return; + } + + // Token warning banner + const warnSessions = sessions.filter(s => s.usagePct >= 70); + if (warnEl && warnSessions.length > 0) { + warnEl.style.display = 'block'; + warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => { + const color = s.usagePct >= 90 ? '#dc2626' : '#d97706'; + return `${s.username} (${s.id.slice(0,8)}…) ${s.usagePct}%`; + }).join('  |  '); + } + + listEl.innerHTML = sessions.map(s => { + const pct = s.usagePct; + const barColor = pct >= 90 ? 'var(--err)' : pct >= 70 ? 'var(--warn)' : 'var(--ok)'; + const lastActive = s.lastActiveAt ? new Date(s.lastActiveAt).toLocaleString() : '-'; + const onlineDot = s.online + ? '' + : ''; + return ` +
+
+
+ ${onlineDot} + ${s.username} + ${s.id.slice(0,12)}… +
+
+ ${lastActive} + ${pct >= 70 ? `${pct}%` : ''} +
+
+
+
+
+
+ ${s.contextTokenEstimate.toLocaleString()} / ${s.numCtx.toLocaleString()} tok +
+
+ 메시지 ${s.historyCount}개 +
+ ${s.lastUserMessage ? `
사용자: ${escHtml(s.lastUserMessage)}
` : ''} + ${s.lastAssistantMessage ? `
AI: ${escHtml(s.lastAssistantMessage)}
` : ''} +
`; + }).join(''); + } catch (e) { + listEl.innerHTML = `
오류: ${e.message || '알 수 없는 오류'}
`; + } +} + +function escHtml(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + // --- 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 => + `${escHtml(String(u))}` + ).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,'&').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) { + 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 '
' @@ -7657,6 +8014,31 @@ function _renderLinkButton(text, url) { + '
' + '
'; } + if (isXlsx) { + var xlsxSafeUrl = escHtml(url); + return '
' + + '
' + + '📊' + + '' + escHtml(text) + '' + + '' + + '⬇ 저장' + + '
' + + '' + + '
'; + } + var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase()); + if (isTxt) { + var txtLabel = escHtml(text.replace(/^📝\s*/, '')); + return '
' + + '
' + + '📝' + + '' + txtLabel + '' + + '' + + '⬇ 저장' + + '
' + + '' + + '
'; + } if (isDownload) { return '' + '⬇ ' + text + ''; @@ -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 = '▲ 접기'; + 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 += '
' + escHtml(sn) + '
'; + html += '
'; + rows.slice(0, 50).forEach(function(row, ri) { + html += ''; + 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 ?? '')) + ''; + }); + html += ''; + }); + if (rows.length > 50) html += ''; + html += '
… ' + (rows.length - 50) + '행 더

'; + }); + 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 = '◀ 미리보기'; + } +} + +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, { 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 = '◀ 미리보기'; + } +} + 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 += '
' + escHtml(sn) + '
'; + previewHtml += '
'; + rows.slice(0, 30).forEach(function(row, ri) { + previewHtml += ''; + row.forEach(function(cell) { + var tag = ri === 0 ? 'th' : 'td'; + previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + ''; + }); + previewHtml += ''; + }); + if (rows.length > 30) previewHtml += ''; + previewHtml += '
… ' + (rows.length - 30) + '행 더
'; + }); + xlsxUploadPromise.then(function(d) { + var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null; + stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml }); }); - 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 '
' + '
' + '📄' - + '' + escHtml(a.name) + '' - + (a.url ? '' : '') + + '' + escHtml(a.name) + '' + + (txtPreview ? '' : '') + + (a.url ? '' : '') + + (a.txtUrl ? '📝⬇' : '') + '' + '
' + + (pagesInfo ? '
텍스트 추출 완료' + pagesInfo + '
' : '') + + (txtPreview ? '' : '') + '' + '
'; } @@ -8063,7 +8564,21 @@ function renderStagedAttachments() { + ''; } - // 기타 카드 (text, csv, excel) + // excel 카드 + if (a.type === 'excel') { + return '
' + + '
' + + '📊' + + '' + escHtml(a.name) + '' + + (a.excelPreviewHtml ? '' : '') + + (a.url ? '⬇ 저장' : '') + + '' + + '
' + + (a.excelPreviewHtml ? '' : '') + + '
'; + } + + // 기타 카드 (text, csv) return '
' + '
' + '' + icon + '' @@ -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);