- 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
+91 -13
View File
@@ -27,7 +27,7 @@ import {
import { getVault, SecretValue } from '../security/vault'; import { getVault, SecretValue } from '../security/vault';
import { getOllamaClient } from '../agents/ollama-client'; import { getOllamaClient } from '../agents/ollama-client';
import { spawnAgent } from '../agents/spawner'; 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 { hookBus } from './hooks';
import { loadWorkspaceHooks } from './hook-loader'; import { loadWorkspaceHooks } from './hook-loader';
import { runBootMd } from './boot'; import { runBootMd } from './boot';
@@ -154,6 +154,27 @@ import {
} from './agent-builder-integration'; } 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 = getConfig().getConfig();
const CONFIG_DIR_PATH = getConfig().getConfigDir(); const CONFIG_DIR_PATH = getConfig().getConfigDir();
const PORT = config.gateway.port || (process.env.GATEWAY_PORT ? parseInt(process.env.GATEWAY_PORT, 10) : 18789); const PORT = config.gateway.port || (process.env.GATEWAY_PORT ? parseInt(process.env.GATEWAY_PORT, 10) : 18789);
@@ -934,7 +955,7 @@ const TOOL_BLOCKS: Record<string, string> = {
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 <path> <url>") 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.`, 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 <path> <url>") 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.`, 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 bootAllowedTools = new Set(['list_files', 'read_file']);
const configuredWorkspace = getConfig().getWorkspacePath(); const configuredWorkspace = getConfig().getWorkspacePath();
const sessionWorkspace = getWorkspace(sessionId); 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) { if (workspacePath && sessionWorkspace !== workspacePath) {
setWorkspace(sessionId, workspacePath); setWorkspace(sessionId, workspacePath);
} }
@@ -7042,6 +7065,18 @@ app.delete('/api/users/:username', (req, res) => {
res.json({ success: true }); 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'); } })); 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) => { app.post('/api/upload/image', (req: express.Request, res: express.Response) => {
const contentType = String(req.headers['content-type'] || ''); const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) { 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 workspacePath = uploadUser?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
const uploadsDir = path.join(workspacePath, 'uploads'); const uploadsDir = path.join(workspacePath, 'uploads');
fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(uploadsDir, { recursive: true });
const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase(); const finalName = resolveUploadName(uploadsDir, filename);
const uniqueName = `${Date.now()}-${safeName}`; const filePath = path.join(uploadsDir, finalName);
const filePath = path.join(uploadsDir, uniqueName);
fs.writeFileSync(filePath, fileData); 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 }); 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) }); }); 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 workspacePath = uploadUser?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
const uploadsDir = path.join(workspacePath, 'uploads'); const uploadsDir = path.join(workspacePath, 'uploads');
fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(uploadsDir, { recursive: true });
const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase(); const finalName = resolveUploadName(uploadsDir, filename);
const uniqueName = `${Date.now()}-${safeName}`; const filePath = path.join(uploadsDir, finalName);
const filePath = path.join(uploadsDir, uniqueName);
fs.writeFileSync(filePath, fileData); fs.writeFileSync(filePath, fileData);
const relativePath = `uploads/${uniqueName}`; const relativePath = `uploads/${finalName}`;
res.json({ success: true, path: relativePath, url: `/api/files/${relativePath}`, filename, size: fileData.length, type: filetype }); 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) }); }); 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)); 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 totalMem = osModule.totalmem();
const freeMem = osModule.freemem(); const freeMem = osModule.freemem();
const usedMem = totalMem - 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' }; 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({ res.json({
system: { system: {
cpu_percent: cpuPercent, cpu_percent: cpuPercent,
@@ -9302,6 +9367,7 @@ app.get('/api/system-stats', async (_req, res) => {
gateway_process: { rss_mb: rss / (1024 * 1024) }, gateway_process: { rss_mb: rss / (1024 * 1024) },
active_provider: (getConfig().getConfig() as any).llm?.provider || 'ollama', 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'; })(), 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(), 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 session = authEnabled && token ? activeSessions.get(token) : undefined;
const user = authEnabled ? (session ? { username: session.username, role: session.role, workspace: getUserWorkspace(session.username) } : null) : null; const user = authEnabled ? (session ? { username: session.username, role: session.role, workspace: getUserWorkspace(session.username) } : null) : null;
(ws as any).user = user; (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})` : ''}`); console.log(`[v2] WS connected${user ? ` (user: ${user.username})` : ''}`);
ws.on('message', (d) => { try { JSON.parse(d.toString()); } catch {} }); ws.on('message', (d) => { try { JSON.parse(d.toString()); } catch {} });
ws.on('close', () => console.log(`[v2] WS disconnected${user ? ` (user: ${user.username})` : ''}`)); 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) => { server.on('error', (err: any) => {
if (err?.code === 'EADDRINUSE') { if (err?.code === 'EADDRINUSE') {
console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`); console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`);
+72
View File
@@ -541,6 +541,78 @@ export function setWorkspace(id: string, workspacePath: string): void {
* *
* Returns a summary of what was copied/skipped. * 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): { export function migrateGlobalSessionsToUser(username: string): {
copied: number; copied: number;
skipped: number; skipped: number;
+609 -38
View File
@@ -244,9 +244,70 @@
} }
#sidebar-jobs, #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; } #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 { .sidebar-section {
padding: 10px 12px; padding: 10px 12px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -937,6 +998,10 @@
.btn { padding: 10px 16px; font-size: 12px; } .btn { padding: 10px 16px; font-size: 12px; }
.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; } .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-green { background: var(--ok); }
.btn-red { background: var(--err); } .btn-red { background: var(--err); }
@@ -1780,16 +1845,28 @@
body.mob-ctx main { display: none; } body.mob-ctx main { display: none; }
body.mob-ctx .right-panel { display: flex; } 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 { body.mob-menu aside {
display: flex; display: flex;
position: fixed; position: fixed;
top: 52px; left: 0; bottom: 56px; top: 0; left: 0; right: 0; bottom: 0;
width: min(300px, 85vw); width: 100%;
z-index: 500; z-index: 600;
box-shadow: 4px 0 24px rgba(0,0,0,0.18);
overflow-y: auto; 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 */ /* Hide desktop resize handle on mobile */
@@ -2204,6 +2281,7 @@
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside> <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"> <div id="sidebar-tabs">
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button> <button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">🧩 스킬</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 class="empty-state" style="padding:16px">스킬 로딩 중...</div>
</div> </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> </aside>
<!-- MAIN --> <!-- MAIN -->
@@ -2550,6 +2640,10 @@
<div class="sys-v" id="sys-ollama">확인 중...</div> <div class="sys-v" id="sys-ollama">확인 중...</div>
<div class="sys-sub" id="sys-runtime-sub">Gateway RSS -- MB • Updated --</div> <div class="sys-sub" id="sys-runtime-sub">Gateway RSS -- MB • Updated --</div>
</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> </div>
<div class="right-section" id="agent-context-section"> <div class="right-section" id="agent-context-section">
@@ -2625,6 +2719,14 @@
<div style="display:flex;gap:6px"> <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="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> <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="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> <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> </div>
@@ -2751,6 +2853,7 @@
<button id="settings-tab-channels" class="sidebar-action" onclick="setSettingsTab('channels')">채널</button> <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-ppt" class="sidebar-action" onclick="setSettingsTab('ppt')">프레젠테이션</button>
<button id="settings-tab-integrations" class="sidebar-action" onclick="setSettingsTab('integrations')">연동</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>
<div id="settings-panel-system" style="padding:14px 16px;display:grid;grid-template-columns:1fr 1fr;gap:14px"> <div id="settings-panel-system" style="padding:14px 16px;display:grid;grid-template-columns:1fr 1fr;gap:14px">
@@ -3654,6 +3757,17 @@
</div> </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)"> <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" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSettings()">취소</button>
<button class="btn btn-sm" onclick="saveSettings()">저장</button> <button class="btn btn-sm" onclick="saveSettings()">저장</button>
@@ -3693,6 +3807,62 @@ let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication 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 // Chat state
let chatHistory = []; let chatHistory = [];
let chatSessions = []; let chatSessions = [];
@@ -5343,6 +5513,87 @@ function clearChat() {
renderChatMessages(); 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 // Enter to send, Shift+Enter for newline
document.getElementById('chat-input').addEventListener('keydown', e => { document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -5664,7 +5915,7 @@ function setQuickThinkingEffort(level) {
function setSettingsTab(tab) { function setSettingsTab(tab) {
settingsTab = 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 => { tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`); const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`); const panel = document.getElementById(`settings-panel-${t}`);
@@ -5683,6 +5934,7 @@ function setSettingsTab(tab) {
if (t === 'credentials') loadCredentialsTab(); if (t === 'credentials') loadCredentialsTab();
if (t === 'ppt') loadPptTab(); if (t === 'ppt') loadPptTab();
if (t === 'users') loadUsersTab(); if (t === 'users') loadUsersTab();
if (t === 'context-viewer') loadContextViewerTab();
} else { } else {
panel.style.display = 'none'; panel.style.display = 'none';
} }
@@ -6290,6 +6542,81 @@ async function removeUser(username) {
} catch (e) { alert(e.message || '삭제 실패'); } } 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 --------------------------------------------------- // --- PPT Template & Skin Tab ---------------------------------------------------
var pptTemplates = []; var pptTemplates = [];
@@ -7196,6 +7523,20 @@ async function loadSystemStats() {
'sys-runtime-sub', 'sys-runtime-sub',
`${memUsage}${memPart} • Gateway ${gatewayRss.toFixed(0)} MB${gpuName ? ` • ${gpuName}` : ''} • ${updated}` `${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 { } catch {
setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%'); setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%');
setText('sys-cpu', '--%'); 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;'); 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) { function _renderLinkButton(text, url) {
url = _normalizeFileUrl(url);
var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3']; var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3'];
var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); }) var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); })
|| fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; }); || fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; });
var decodedUrl = decodeURIComponent(url).toLowerCase(); var decodedUrl = decodeURIComponent(url).toLowerCase();
var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx'); var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf'); 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) { if (isPptx) {
var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9); var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9);
return '<div class="pptx-file-row">' return '<div class="pptx-file-row">'
@@ -7657,6 +8014,31 @@ function _renderLinkButton(text, url) {
+ '</div>' + '</div>'
+ '</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) { if (isDownload) {
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">' return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>'; + '<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) { function renderMarkdown(text) {
if (!text) return ''; if (!text) return '';
@@ -7904,22 +8371,43 @@ function handleFileUpload(input) {
// Excel // Excel
} else if (/\.(xlsx|xls)$/.test(name)) { } 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() { loadSheetJS(function() {
var r3 = new FileReader(); var r3 = new FileReader();
r3.onload = function(e) { r3.onload = function(e) {
try { try {
var wb = XLSX.read(e.target.result, { type: 'array' }); 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) { wb.SheetNames.forEach(function(sn) {
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 }); var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
result += '### ' + sn + '\n'; markdownContent += '### ' + sn + '\n';
rows.forEach(function(row) { result += '| ' + row.join(' | ') + ' |\n'; }); rows.forEach(function(row) { markdownContent += '| ' + row.map(function(c) { return String(c ?? ''); }).join(' | ') + ' |\n'; });
result += '\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); } } catch(err) { alert('Excel 파싱 오류: ' + err.message); }
}; };
r3.readAsArrayBuffer(file); r3.readAsArrayBuffer(xlsxFile);
}); });
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장 // PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
@@ -7939,9 +8427,9 @@ function handleFileUpload(input) {
var pages = []; var pages = [];
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i)); for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
Promise.all(pages).then(function(ps) { 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 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) { }).then(function(texts) {
@@ -7956,31 +8444,36 @@ function handleFileUpload(input) {
Promise.all([uploadPromise, extractPromise]).then(function(res) { Promise.all([uploadPromise, extractPromise]).then(function(res) {
var upData = res[0], extracted = res[1]; var upData = res[0], extracted = res[1];
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null; var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
var trimmed = (extracted.text || '').trim().slice(0, 8000); var fullText = (extracted.text || '').trim();
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자만 포함)*';
// PDF 텍스트를 .txt 파일로 자동 저장 → PDF 카드에 txt 버튼 추가 if (fullText) {
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt'); var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' }); var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
var txtForm = new FormData(); var txtForm = new FormData();
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' })); 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 }) fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.success && d.path) { var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
var txtUrl = '/api/files/' + d.path; if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
addProcessEntry('info', '📝 텍스트 저장: ' + txtName); var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')';
// 이미 스테이징된 PDF 카드에 txt 버튼 주입 if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')';
addTxtButtonToPdfCard(pdfFile.name, txtName, txtUrl); content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.';
} stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
}).catch(function() {}); 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 { } else {
content += '*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*'; var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf'); stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
} }
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); }); }).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
@@ -8010,9 +8503,11 @@ function insertIntoChatInput(text) {
// ── 첨부 파일 스테이징 ────────────────────────────────────────────────────── // ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
var stagedAttachments = []; 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); 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(); renderStagedAttachments();
} }
@@ -8037,13 +8532,19 @@ function renderStagedAttachments() {
// PDF 카드 // PDF 카드
if (a.type === '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">' 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">' + '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📄</span>' + '<span>📄</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</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>'
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '') + (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>' + '<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>'
+ (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 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>'; + '</div>';
} }
@@ -8063,7 +8564,21 @@ function renderStagedAttachments() {
+ '</div>'; + '</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">' 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">' + '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>' + icon + '</span>' + '<span>' + icon + '</span>'
@@ -8093,6 +8608,12 @@ function toggleStagedTxtPreview(id) {
el.style.display = el.style.display === 'none' ? 'block' : 'none'; 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 업로드 완료 후 호출) // PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) { function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
// stagedAttachments에서 해당 PDF 찾기 // stagedAttachments에서 해당 PDF 찾기
@@ -8118,6 +8639,57 @@ function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
btnRow.insertBefore(txtBtn, closeBtn); 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 동적 로드 ────────────────────────────────────────────── // ── SheetJS / PDF.js 동적 로드 ──────────────────────────────────────────────
var _sheetJsLoaded = false; var _sheetJsLoaded = false;
function loadSheetJS(cb) { function loadSheetJS(cb) {
@@ -10049,7 +10621,6 @@ function mobTab(tab) {
mobCloseMenu(); mobCloseMenu();
} else { } else {
body.classList.add('mob-menu'); body.classList.add('mob-menu');
document.getElementById('mob-backdrop').style.display = 'block';
} }
// Keep chat/ctx tab highlight unchanged; toggle menu highlight // Keep chat/ctx tab highlight unchanged; toggle menu highlight
document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen); document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen);