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 <noreply@anthropic.com>
This commit is contained in:
+91
-13
@@ -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<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.`,
|
||||
|
||||
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.`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+609
-38
@@ -244,9 +244,70 @@
|
||||
}
|
||||
|
||||
#sidebar-jobs,
|
||||
#sidebar-skills { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
#sidebar-skills { flex: 3; min-height: 0; display: flex; flex-direction: column; }
|
||||
#sidebar-skills { display: none; }
|
||||
|
||||
/* ── Server log panel ─────────────────────────────── */
|
||||
#sidebar-log-panel {
|
||||
flex: 2;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 2px solid var(--line);
|
||||
}
|
||||
#sidebar-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
background: var(--panel-2, var(--panel));
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#sidebar-log-header span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#sidebar-log-header button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
#sidebar-log-header button:hover { background: var(--line); color: var(--text); }
|
||||
#sidebar-log-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px;
|
||||
background: var(--bg, #f7f8fa);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
:root[data-theme="dark"] #sidebar-log-body { background: #0f1117; }
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-line:hover { background: var(--line); }
|
||||
.log-ts { color: var(--muted); flex-shrink: 0; }
|
||||
.log-msg { color: var(--text); word-break: break-all; white-space: pre-wrap; }
|
||||
.log-line.warn .log-msg { color: #b45309; }
|
||||
.log-line.error .log-msg { color: #dc2626; }
|
||||
:root[data-theme="dark"] .log-line.warn .log-msg { color: #fbbf24; }
|
||||
:root[data-theme="dark"] .log-line.error .log-msg { color: #f87171; }
|
||||
|
||||
.sidebar-section {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@@ -937,6 +998,10 @@
|
||||
|
||||
.btn { padding: 10px 16px; font-size: 12px; }
|
||||
.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; }
|
||||
.export-dropdown { position:relative; display:inline-block; }
|
||||
.export-menu { display:none; position:absolute; right:0; top:calc(100% + 4px); background:var(--panel-2); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.18); z-index:2000; min-width:160px; overflow:hidden; }
|
||||
.export-menu button { display:block; width:100%; padding:8px 14px; text-align:left; background:none; border:none; cursor:pointer; font-size:12px; color:var(--text); white-space:nowrap; }
|
||||
.export-menu button:hover { background:var(--bg3); }
|
||||
.btn-green { background: var(--ok); }
|
||||
.btn-red { background: var(--err); }
|
||||
|
||||
@@ -1780,16 +1845,28 @@
|
||||
body.mob-ctx main { display: none; }
|
||||
body.mob-ctx .right-panel { display: flex; }
|
||||
|
||||
/* Sidebar drawer — slides in from left over content */
|
||||
/* Sidebar — full screen overlay when menu tab is active */
|
||||
body.mob-menu aside {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 52px; left: 0; bottom: 56px;
|
||||
width: min(300px, 85vw);
|
||||
z-index: 500;
|
||||
box-shadow: 4px 0 24px rgba(0,0,0,0.18);
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 600;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
animation: mobMenuSlideUp 0.22s ease;
|
||||
padding-top: 52px;
|
||||
}
|
||||
body.mob-menu aside #mob-menu-close {
|
||||
display: block !important;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
@keyframes mobMenuSlideUp {
|
||||
from { transform: translateY(5%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Hide desktop resize handle on mobile */
|
||||
@@ -2204,6 +2281,7 @@
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside>
|
||||
<button id="mob-menu-close" onclick="mobTab('chat')" style="display:none;position:absolute;top:12px;right:12px;z-index:10;border:1px solid var(--line);background:var(--panel-2);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:700;color:var(--muted);cursor:pointer;font-family:'Manrope',sans-serif">✕ 닫기</button>
|
||||
<div id="sidebar-tabs">
|
||||
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
|
||||
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">🧩 스킬</button>
|
||||
@@ -2233,6 +2311,18 @@
|
||||
<div class="empty-state" style="padding:16px">스킬 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVER LOG PANEL -->
|
||||
<div id="sidebar-log-panel">
|
||||
<div id="sidebar-log-header">
|
||||
<span>서버 로그</span>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button id="log-scroll-lock-btn" onclick="toggleLogScrollLock()" title="자동 스크롤 고정">↓</button>
|
||||
<button onclick="clearLogPanel()" title="지우기">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sidebar-log-body"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN -->
|
||||
@@ -2550,6 +2640,10 @@
|
||||
<div class="sys-v" id="sys-ollama">확인 중...</div>
|
||||
<div class="sys-sub" id="sys-runtime-sub">Gateway RSS -- MB • Updated --</div>
|
||||
</div>
|
||||
<div class="sys-card full" onclick="openSettings('context-viewer')" title="컨텍스트 뷰어 열기" style="cursor:pointer">
|
||||
<div class="sys-k">접속자</div>
|
||||
<div class="sys-v" id="sys-online-users" style="font-size:12px;font-weight:600;letter-spacing:0">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-section" id="agent-context-section">
|
||||
@@ -2625,6 +2719,14 @@
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="clearChat()">✕ Chat</button>
|
||||
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="newChatSession()">+</button>
|
||||
<div class="export-dropdown">
|
||||
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="toggleExportMenu(event)" title="채팅 내보내기">↓ 내보내기</button>
|
||||
<div class="export-menu" id="export-menu">
|
||||
<button onclick="exportChat('md')">📄 Markdown (.md)</button>
|
||||
<button onclick="exportChat('txt')">📝 텍스트 (.txt)</button>
|
||||
<button onclick="exportChat('json')">{ } JSON (.json)</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="openSettings()">설정</button>
|
||||
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="setSidebarTab('skills')">🧩</button>
|
||||
</div>
|
||||
@@ -2751,6 +2853,7 @@
|
||||
<button id="settings-tab-channels" class="sidebar-action" onclick="setSettingsTab('channels')">채널</button>
|
||||
<button id="settings-tab-ppt" class="sidebar-action" onclick="setSettingsTab('ppt')">프레젠테이션</button>
|
||||
<button id="settings-tab-integrations" class="sidebar-action" onclick="setSettingsTab('integrations')">연동</button>
|
||||
<button id="settings-tab-context-viewer" class="sidebar-action" onclick="setSettingsTab('context-viewer')">컨텍스트 뷰어</button>
|
||||
</div>
|
||||
|
||||
<div id="settings-panel-system" style="padding:14px 16px;display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||||
@@ -3654,6 +3757,17 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div id="settings-panel-context-viewer" style="display:none;padding:14px 16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<div class="right-section-title">사용자 컨텍스트 뷰어</div>
|
||||
<button class="btn btn-sm" style="background:var(--panel-2);border:1px solid var(--line);color:var(--muted)" onclick="loadContextViewerTab()">새로고침</button>
|
||||
</div>
|
||||
<div id="cv-warn-banner" style="display:none;padding:8px 12px;background:#fff7ed;border:1px solid #fbbf24;border-radius:8px;font-size:12px;color:#92400e;margin-bottom:12px"></div>
|
||||
<div id="cv-sessions-list" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line)">
|
||||
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSettings()">취소</button>
|
||||
<button class="btn btn-sm" onclick="saveSettings()">저장</button>
|
||||
@@ -3693,6 +3807,62 @@ let currentUser = null;
|
||||
setAgentSessionId(generateSessionId()); // always fresh session on page load
|
||||
checkAuth(); // Check authentication on page load
|
||||
|
||||
// ── Server log panel ──────────────────────────────────────────────────────────
|
||||
let logScrollLocked = true;
|
||||
let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size
|
||||
|
||||
function toggleLogScrollLock() {
|
||||
logScrollLocked = !logScrollLocked;
|
||||
const btn = document.getElementById('log-scroll-lock-btn');
|
||||
if (btn) btn.style.opacity = logScrollLocked ? '1' : '0.4';
|
||||
}
|
||||
|
||||
function clearLogPanel() {
|
||||
const body = document.getElementById('sidebar-log-body');
|
||||
if (body) body.innerHTML = '';
|
||||
logLastTotalCount = 0;
|
||||
}
|
||||
|
||||
function renderLogEntries(entries) {
|
||||
const body = document.getElementById('sidebar-log-body');
|
||||
if (!body) return;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const e of entries) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'log-line ' + (e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : '');
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'log-ts';
|
||||
ts.textContent = e.ts;
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'log-msg';
|
||||
msg.textContent = e.msg;
|
||||
line.appendChild(ts);
|
||||
line.appendChild(msg);
|
||||
frag.appendChild(line);
|
||||
}
|
||||
body.appendChild(frag);
|
||||
if (logScrollLocked) body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
async function pollServerLogs() {
|
||||
try {
|
||||
const data = await api('/api/logs');
|
||||
const entries = data?.entries || [];
|
||||
const totalCount = data?.totalCount ?? entries.length;
|
||||
if (totalCount <= logLastTotalCount) return;
|
||||
|
||||
const newCount = totalCount - logLastTotalCount;
|
||||
// Slice new entries from the end of the ring (handles wrap-around correctly)
|
||||
const newEntries = entries.slice(Math.max(0, entries.length - newCount));
|
||||
renderLogEntries(newEntries);
|
||||
logLastTotalCount = totalCount;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setInterval(pollServerLogs, 2000);
|
||||
pollServerLogs();
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Chat state
|
||||
let chatHistory = [];
|
||||
let chatSessions = [];
|
||||
@@ -5343,6 +5513,87 @@ function clearChat() {
|
||||
renderChatMessages();
|
||||
}
|
||||
|
||||
function toggleExportMenu(e) {
|
||||
if (e) e.stopPropagation();
|
||||
var menu = document.getElementById('export-menu');
|
||||
if (!menu) return;
|
||||
var opening = menu.style.display === 'none' || !menu.style.display;
|
||||
menu.style.display = opening ? 'block' : 'none';
|
||||
if (opening) {
|
||||
setTimeout(function() {
|
||||
document.addEventListener('click', function closeExport() {
|
||||
menu.style.display = 'none';
|
||||
document.removeEventListener('click', closeExport);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function exportChat(format) {
|
||||
var menu = document.getElementById('export-menu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
|
||||
if (!chatHistory || !chatHistory.length) {
|
||||
alert('내보낼 채팅 내용이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
var sess = chatSessions.find(function(s) { return s.id === activeChatSessionId; });
|
||||
var title = (sess && sess.title) ? sess.title : '채팅';
|
||||
var dateStr = new Date().toLocaleString('ko-KR');
|
||||
var safeName = title.replace(/[^\w가-힣\s-]/g, '').trim().replace(/\s+/g, '_').slice(0, 40) || 'chat';
|
||||
var dateSuffix = new Date().toISOString().slice(0, 10);
|
||||
var filename = safeName + '_' + dateSuffix;
|
||||
|
||||
var content, mime, ext;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify({
|
||||
title: title,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sessionId: activeChatSessionId,
|
||||
messages: chatHistory.map(function(m) {
|
||||
return { role: m.role, content: (m.display || m.content || '').trim() };
|
||||
})
|
||||
}, null, 2);
|
||||
mime = 'application/json';
|
||||
ext = 'json';
|
||||
|
||||
} else if (format === 'md') {
|
||||
var lines = ['# ' + title, '', '> 내보낸 시간: ' + dateStr, ''];
|
||||
chatHistory.forEach(function(m) {
|
||||
var label = m.role === 'user' ? '## 사용자' : '## 어시스턴트';
|
||||
var text = (m.display || m.content || '').trim();
|
||||
lines.push(label, '', text, '', '---', '');
|
||||
});
|
||||
content = lines.join('\n');
|
||||
mime = 'text/markdown';
|
||||
ext = 'md';
|
||||
|
||||
} else {
|
||||
var lines = [title, '내보낸 시간: ' + dateStr, '='.repeat(50), ''];
|
||||
chatHistory.forEach(function(m) {
|
||||
var label = m.role === 'user' ? '[사용자]' : '[AI]';
|
||||
var text = (m.display || m.content || '').trim()
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '[이미지]')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[*_`#>]/g, '');
|
||||
lines.push(label, text, '');
|
||||
});
|
||||
content = lines.join('\n');
|
||||
mime = 'text/plain';
|
||||
ext = 'txt';
|
||||
}
|
||||
|
||||
var blob = new Blob([content], { type: mime + '; charset=utf-8' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename + '.' + ext;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||||
}
|
||||
|
||||
// Enter to send, Shift+Enter for newline
|
||||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -5664,7 +5915,7 @@ function setQuickThinkingEffort(level) {
|
||||
|
||||
function setSettingsTab(tab) {
|
||||
settingsTab = tab;
|
||||
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt'];
|
||||
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt', 'context-viewer'];
|
||||
tabs.forEach(t => {
|
||||
const btn = document.getElementById(`settings-tab-${t}`);
|
||||
const panel = document.getElementById(`settings-panel-${t}`);
|
||||
@@ -5683,6 +5934,7 @@ function setSettingsTab(tab) {
|
||||
if (t === 'credentials') loadCredentialsTab();
|
||||
if (t === 'ppt') loadPptTab();
|
||||
if (t === 'users') loadUsersTab();
|
||||
if (t === 'context-viewer') loadContextViewerTab();
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
@@ -6290,6 +6542,81 @@ async function removeUser(username) {
|
||||
} catch (e) { alert(e.message || '삭제 실패'); }
|
||||
}
|
||||
|
||||
// --- Context Viewer Tab -------------------------------------------------------
|
||||
|
||||
let cvAutoRefreshTimer = null;
|
||||
|
||||
async function loadContextViewerTab() {
|
||||
const listEl = document.getElementById('cv-sessions-list');
|
||||
const warnEl = document.getElementById('cv-warn-banner');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>';
|
||||
if (warnEl) warnEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = await api('/api/admin/context-viewer');
|
||||
if (!data || !data.sessions) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--err)">권한이 없거나 데이터를 불러올 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
const sessions = data.sessions;
|
||||
if (sessions.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">세션이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Token warning banner
|
||||
const warnSessions = sessions.filter(s => s.usagePct >= 70);
|
||||
if (warnEl && warnSessions.length > 0) {
|
||||
warnEl.style.display = 'block';
|
||||
warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => {
|
||||
const color = s.usagePct >= 90 ? '#dc2626' : '#d97706';
|
||||
return `<strong style="color:${color}">${s.username}</strong> (${s.id.slice(0,8)}…) ${s.usagePct}%`;
|
||||
}).join(' | ');
|
||||
}
|
||||
|
||||
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,'&').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 =>
|
||||
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok)"></span>${escHtml(String(u))}</span>`
|
||||
).join('');
|
||||
onlineEl.style.color = 'var(--text)';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%');
|
||||
setText('sys-cpu', '--%');
|
||||
@@ -7627,13 +7968,29 @@ function escHtml(str) {
|
||||
return String(str).replace(/&/g,'&').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 '<div class="pptx-file-row">'
|
||||
@@ -7657,6 +8014,31 @@ function _renderLinkButton(text, url) {
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
if (isXlsx) {
|
||||
var xlsxSafeUrl = escHtml(url);
|
||||
return '<div class="pdf-file-row xlsx-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
|
||||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||||
+ '<span style="font-size:14px">📊</span>'
|
||||
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
|
||||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)">◀ 미리보기</button>'
|
||||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</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)">◀ 미리보기</button>'
|
||||
+ '<a href="' + escHtml(url) + '" download="' + txtLabel + '" class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</a>'
|
||||
+ '</div>'
|
||||
+ '<div class="txt-preview-body" style="display:none;max-height:420px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;word-break:break-word;line-height:1.7;color:var(--text);font-family:inherit"></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
if (isDownload) {
|
||||
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
|
||||
+ '<span class="download-icon">⬇</span> ' + text + '</a>';
|
||||
@@ -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 += '<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 = '◀ 미리보기';
|
||||
}
|
||||
}
|
||||
|
||||
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 += '<div style="margin-bottom:6px;font-weight:700;font-size:11px">' + escHtml(sn) + '</div>';
|
||||
previewHtml += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
|
||||
rows.slice(0, 30).forEach(function(row, ri) {
|
||||
previewHtml += '<tr>';
|
||||
row.forEach(function(cell) {
|
||||
var tag = ri === 0 ? 'th' : 'td';
|
||||
previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '</' + tag + '>';
|
||||
});
|
||||
previewHtml += '</tr>';
|
||||
});
|
||||
if (rows.length > 30) previewHtml += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 30) + '행 더</td></tr>';
|
||||
previewHtml += '</table></div>';
|
||||
});
|
||||
xlsxUploadPromise.then(function(d) {
|
||||
var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
|
||||
stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml });
|
||||
});
|
||||
stageAttachment(file.name, null, result, 'excel');
|
||||
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
|
||||
};
|
||||
r3.readAsArrayBuffer(file);
|
||||
r3.readAsArrayBuffer(xlsxFile);
|
||||
});
|
||||
|
||||
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
|
||||
@@ -7939,9 +8427,9 @@ function handleFileUpload(input) {
|
||||
var pages = [];
|
||||
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
|
||||
Promise.all(pages).then(function(ps) {
|
||||
return Promise.all(ps.map(function(p) {
|
||||
return Promise.all(ps.map(function(p, pi) {
|
||||
return p.getTextContent().then(function(tc) {
|
||||
return tc.items.map(function(it) { return it.str; }).join(' ');
|
||||
return pdfPageToText(tc.items, pi + 1, pdf.numPages);
|
||||
});
|
||||
}));
|
||||
}).then(function(texts) {
|
||||
@@ -7956,31 +8444,36 @@ function handleFileUpload(input) {
|
||||
Promise.all([uploadPromise, extractPromise]).then(function(res) {
|
||||
var upData = res[0], extracted = res[1];
|
||||
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
|
||||
var trimmed = (extracted.text || '').trim().slice(0, 8000);
|
||||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n';
|
||||
if (trimmed) {
|
||||
content += '**[PDF 내용 — ' + extracted.pages + '페이지]**\n\n' + trimmed;
|
||||
if ((extracted.text || '').trim().length > 8000) content += '\n\n*(앞 8000자만 포함)*';
|
||||
var fullText = (extracted.text || '').trim();
|
||||
|
||||
// PDF 텍스트를 .txt 파일로 자동 저장 → PDF 카드에 txt 버튼 추가
|
||||
if (fullText) {
|
||||
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
|
||||
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
|
||||
var txtForm = new FormData();
|
||||
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
|
||||
// PDF 카드 먼저 추가 (txt 저장 대기 없이)
|
||||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
|
||||
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.path) {
|
||||
var txtUrl = '/api/files/' + d.path;
|
||||
addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
|
||||
// 이미 스테이징된 PDF 카드에 txt 버튼 주입
|
||||
addTxtButtonToPdfCard(pdfFile.name, txtName, txtUrl);
|
||||
}
|
||||
}).catch(function() {});
|
||||
var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
|
||||
if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
|
||||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')';
|
||||
if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')';
|
||||
content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.';
|
||||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
|
||||
txtPreview: fullText.slice(0, 500),
|
||||
txtUrl: txtUrl,
|
||||
txtName: txtName,
|
||||
pages: extracted.pages
|
||||
});
|
||||
}).catch(function() {
|
||||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n이 파일로 무엇을 도와드릴까요?';
|
||||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
|
||||
txtPreview: fullText.slice(0, 500),
|
||||
pages: extracted.pages
|
||||
});
|
||||
});
|
||||
} else {
|
||||
content += '*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
|
||||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
|
||||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
|
||||
}
|
||||
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
|
||||
@@ -8010,9 +8503,11 @@ function insertIntoChatInput(text) {
|
||||
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
|
||||
var stagedAttachments = [];
|
||||
|
||||
function stageAttachment(name, url, content, type) {
|
||||
function stageAttachment(name, url, content, type, extra) {
|
||||
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
|
||||
stagedAttachments.push({ id: id, name: name, url: url, content: content, type: type });
|
||||
var att = { id: id, name: name, url: url, content: content, type: type };
|
||||
if (extra) Object.assign(att, extra);
|
||||
stagedAttachments.push(att);
|
||||
renderStagedAttachments();
|
||||
}
|
||||
|
||||
@@ -8037,13 +8532,19 @@ function renderStagedAttachments() {
|
||||
|
||||
// PDF 카드
|
||||
if (a.type === 'pdf') {
|
||||
var txtPreview = a.txtPreview ? escHtml(a.txtPreview) + (a.txtPreview.length >= 500 ? '...' : '') : '';
|
||||
var pagesInfo = a.pages ? ' (' + a.pages + '페이지)' : '';
|
||||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>📄</span>'
|
||||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||||
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
|
||||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(a.name) + pagesInfo + '">' + escHtml(a.name) + '</span>'
|
||||
+ (txtPreview ? '<button onclick="toggleStagedTxtPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">txt 미리보기</button>' : '')
|
||||
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">PDF</button>' : '')
|
||||
+ (a.txtUrl ? '<a href="' + escHtml(a.txtUrl) + '" download="' + escHtml(a.txtName || '') + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="txt 다운로드">📝⬇</a>' : '')
|
||||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||||
+ '</div>'
|
||||
+ (pagesInfo ? '<div style="font-size:10px;color:var(--muted);padding-left:2px">텍스트 추출 완료' + pagesInfo + '</div>' : '')
|
||||
+ (txtPreview ? '<div id="txt-pdf-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + txtPreview + '</div>' : '')
|
||||
+ '<div id="pdf-preview-' + a.id + '" style="display:none"><iframe data-src="' + escHtml(a.url || '') + '" style="width:100%;height:400px;border:1px solid var(--line);border-radius:6px"></iframe></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
@@ -8063,7 +8564,21 @@ function renderStagedAttachments() {
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// 기타 카드 (text, csv, excel)
|
||||
// excel 카드
|
||||
if (a.type === 'excel') {
|
||||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:360px">'
|
||||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>📊</span>'
|
||||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||||
+ (a.excelPreviewHtml ? '<button onclick="toggleStagedExcelPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
|
||||
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block">⬇ 저장</a>' : '')
|
||||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||||
+ '</div>'
|
||||
+ (a.excelPreviewHtml ? '<div id="excel-staged-' + a.id + '" style="display:none;max-height:260px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px">' + a.excelPreviewHtml + '</div>' : '')
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// 기타 카드 (text, csv)
|
||||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>' + icon + '</span>'
|
||||
@@ -8093,6 +8608,12 @@ function toggleStagedTxtPreview(id) {
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function toggleStagedTxtPdfPreview(id) {
|
||||
var el = document.getElementById('txt-pdf-preview-' + id);
|
||||
if (!el) return;
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
|
||||
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
|
||||
// stagedAttachments에서 해당 PDF 찾기
|
||||
@@ -8118,6 +8639,57 @@ function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
|
||||
btnRow.insertBefore(txtBtn, closeBtn);
|
||||
}
|
||||
|
||||
// ── PDF 페이지 텍스트 구조화 ────────────────────────────────────────────────
|
||||
function pdfPageToText(items, pageNum, totalPages) {
|
||||
// filter out empty items
|
||||
var valid = items.filter(function(it) { return it.str && it.str.trim() !== ''; });
|
||||
if (!valid.length) return '';
|
||||
|
||||
// sort top→bottom (y desc), left→right (x asc)
|
||||
valid.sort(function(a, b) {
|
||||
var ay = a.transform[5], by = b.transform[5];
|
||||
if (Math.abs(ay - by) > 2) return by - ay;
|
||||
return a.transform[4] - b.transform[4];
|
||||
});
|
||||
|
||||
// group items into lines by y-coordinate proximity
|
||||
var LINE_Y_THRESH = 3;
|
||||
var lines = [];
|
||||
var cur = null;
|
||||
valid.forEach(function(item) {
|
||||
var y = item.transform[5];
|
||||
var h = item.height || 10;
|
||||
if (!cur || Math.abs(y - cur.y) > LINE_Y_THRESH) {
|
||||
cur = { y: y, h: h, items: [item] };
|
||||
lines.push(cur);
|
||||
} else {
|
||||
cur.items.push(item);
|
||||
if (h > cur.h) cur.h = h;
|
||||
}
|
||||
});
|
||||
|
||||
// build output: join items per line; insert blank line on large y-gaps (paragraph break)
|
||||
var out = [];
|
||||
if (totalPages > 1) out.push('── ' + pageNum + '페이지 ──');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var lineText = lines[i].items.map(function(it) { return it.str; }).join('').replace(/\s+/g, ' ').trim();
|
||||
if (!lineText) continue;
|
||||
|
||||
// paragraph break: gap > 1.4× current line height
|
||||
if (i > 0) {
|
||||
var gap = lines[i - 1].y - lines[i].y;
|
||||
if (gap > lines[i - 1].h * 1.4) out.push('');
|
||||
}
|
||||
// fix hyphenation: merge "단어-\n다음줄" → "단어다음줄"
|
||||
if (out.length && out[out.length - 1].endsWith('-')) {
|
||||
out[out.length - 1] = out[out.length - 1].slice(0, -1) + lineText;
|
||||
} else {
|
||||
out.push(lineText);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── SheetJS / PDF.js 동적 로드 ──────────────────────────────────────────────
|
||||
var _sheetJsLoaded = false;
|
||||
function loadSheetJS(cb) {
|
||||
@@ -10049,7 +10621,6 @@ function mobTab(tab) {
|
||||
mobCloseMenu();
|
||||
} else {
|
||||
body.classList.add('mob-menu');
|
||||
document.getElementById('mob-backdrop').style.display = 'block';
|
||||
}
|
||||
// Keep chat/ctx tab highlight unchanged; toggle menu highlight
|
||||
document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen);
|
||||
|
||||
Reference in New Issue
Block a user