This commit is contained in:
kim
2026-05-06 22:45:28 +09:00
parent dac32dbcee
commit a77ee3bdc9
204 changed files with 2407 additions and 5205 deletions
+348 -7
View File
@@ -14,6 +14,10 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
@@ -2608,6 +2612,10 @@
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
저장
</button>
<button id="canvas-run-btn" onclick="canvasRun()" title="코드 실행 (터미널)" style="display:flex;align-items:center;gap:5px;border:1px solid #333;background:#1a1a1a;color:#28c840;border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'IBM Plex Mono',monospace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>
▶ 실행
</button>
<button onclick="canvasAddToContext()" title="채팅 입력에 추가" style="display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;color:var(--text);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Add to Chat
@@ -2619,13 +2627,28 @@
</div>
<!-- Canvas body -->
<div style="flex:1;min-height:0;display:flex;flex-direction:column;position:relative">
<div id="canvas-empty-state" style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--muted);text-align:center;padding:24px;pointer-events:none">
<div id="canvas-empty-state" style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--muted);text-align:center;padding:24px;pointer-events:none;z-index:1">
<svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.25"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
<div style="font-size:13px;font-weight:700">열린 파일 없음</div>
<div style="font-size:11px;line-height:1.7">Drop a file here or click <strong>+</strong> to create one</div>
</div>
<div id="canvas-editor-wrap" style="display:none;flex:1;min-height:0;overflow:hidden"></div>
<iframe id="canvas-preview-frame" style="display:none;flex:1;border:none;width:100%;min-height:0"></iframe>
<!-- Terminal panel (VSCode-style, bottom) -->
<div id="canvas-terminal" style="display:flex;flex-direction:column;border-top:1px solid #333;background:rgba(12,12,12,0.9);height:50%;min-height:32px;flex-shrink:0;margin-top:auto">
<!-- Drag handle -->
<div id="canvas-term-drag" style="height:3px;cursor:ns-resize;background:#333;flex-shrink:0;transition:background .15s"></div>
<!-- Shell tab bar -->
<div style="display:flex;align-items:center;height:30px;background:#0C0C0C;font-size:13px;flex-shrink:0;overflow-x:auto">
<div id="canvas-shell-tabs" style="display:flex;align-items:stretch;height:100%"></div>
<button onclick="addTermShell()" title="새 쉘" style="background:0;border:0;color:#767676;cursor:pointer;font-size:16px;line-height:1;padding:0 8px;flex-shrink:0">+</button>
<div style="flex:1"></div>
<span id="canvas-run-status" style="font-size:10px;color:#767676;margin-right:4px"></span>
<button onclick="document.getElementById('canvas-terminal').style.display='none'" style="background:0;border:0;color:#767676;cursor:pointer;font-size:14px;line-height:1;padding:2px 4px;flex-shrink:0" title="패널 닫기">&times;</button>
</div>
<!-- xterm.js terminal container -->
<div id="canvas-term-container" style="flex:1;min-height:0;padding:0 4px;background:#0C0C0C;position:relative"></div>
</div>
</div>
</div>
<!-- End Canvas Overlay -->
@@ -2751,7 +2774,7 @@
</div>
<!-- AGENT EXECUTION PANEL -->
<div style="display:flex;flex-direction:column;max-height:200px;overflow:hidden;border:1px solid var(--border);border-radius:10px;background:var(--panel-2);margin-bottom:10px">
<div id="agent-panel" style="display:flex;flex-direction:column;max-height:200px;overflow:hidden;border:1px solid var(--border);border-radius:10px;background:var(--panel-2);margin-bottom:10px">
<div style="padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<div style="font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:8px">활성 에이전트</div>
</div>
@@ -2761,7 +2784,7 @@
</div>
<!-- PROCESS LOG -->
<div style="display:flex;flex-direction:column;flex:1 1 320px;overflow:hidden;min-height:280px">
<div id="process-section" style="display:flex;flex-direction:column;flex:1 1 320px;overflow:hidden;min-height:280px">
<div style="padding:8px 16px 6px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-shrink:0">
<a href="javascript:void(0)" onclick="openProcessLogFile()" style="font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);text-decoration:none;cursor:pointer;hover:color:var(--brand)">프로세스 로그</a>
<button onclick="clearProcessLog()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:10px;font-family:inherit">✕ clear</button>
@@ -4628,7 +4651,6 @@ function renderChatMessages() {
return;
}
_dlLinkCache = {}; // reset on full re-render so moved/deleted files recheck
container.innerHTML = chatHistory.map((msg, idx) => {
const pinClass = contextPinMode ? ' pin-selectable' : '';
const selectedClass = (contextPinMode && pinnedMessageIndices.has(idx)) ? ' pin-selected' : '';
@@ -8469,13 +8491,12 @@ function validateDownloadLinks(container) {
var url = btn.getAttribute('href');
if (!url || url.indexOf('/api/files/') === -1) return;
if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; }
if (_dlLinkCache[url] === true) return;
if (_dlLinkCache[url] === true || _dlLinkCache[url] === 'pending') return;
_dlLinkCache[url] = 'pending';
fetch(url, { method: 'HEAD', headers: authHeaders() })
.then(function(r) {
_dlLinkCache[url] = r.ok;
if (!r.ok) {
// btn may still be in DOM; re-query in case renderMessages rebuilt it
var found = (container || document).querySelector('a.download-btn[href="' + url.replace(/"/g, '\\"') + '"]');
if (found) markDownloadLinkError(found);
}
@@ -9209,15 +9230,34 @@ function toggleCanvas() {
canvasOpen = !canvasOpen;
const panel = document.getElementById('canvas-panel');
const btn = document.getElementById('canvas-toggle-btn');
const hideEls = ['system-stats-section', 'process-section', 'agent-panel'];
if (!panel) return;
if (canvasOpen) {
panel.style.display = 'flex';
if (btn) { btn.style.background='#eaf2ff'; btn.style.borderColor='#bdd3f6'; btn.style.color='#0d4faf'; }
if (!canvasEditorInitialized) initCanvasEditor();
canvasRenderTabs();
hideEls.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
setTimeout(() => {
const shell = getActiveShell();
if (shell && !shell.term) {
const container = document.getElementById('canvas-term-container');
if (container) {
const result = createXtermShell(container, shell.id);
shell.term = result.term;
shell.fitAddon = result.fitAddon;
shell.ptyReady = result.inst.ptyReady;
}
}
if (shell && shell.term) {
shell.fitAddon.fit();
shell.term.focus();
}
}, 150);
} else {
panel.style.display = 'none';
if (btn) { btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; }
hideEls.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = ''; });
}
}
@@ -9313,7 +9353,7 @@ function applyCanvasViewMode(mode, tab) {
if (previewModeBtn) previewModeBtn.classList.add('active');
canvasUpdatePreview();
} else {
if (editorWrap) { editorWrap.style.display = 'flex'; editorWrap.style.flex = '1'; }
if (editorWrap) { editorWrap.style.display = 'block'; editorWrap.style.flex = '1'; }
if (previewFrame) previewFrame.style.display = 'none';
if (codeModeBtn) codeModeBtn.classList.add('active');
if (previewModeBtn) previewModeBtn.classList.remove('active');
@@ -9393,6 +9433,307 @@ function canvasAddToContext() {
addProcessEntry('info', `Canvas: sent ${tab.name} to LLM as context.`);
}
// ── xterm.js Terminal ────────────────────────────────────────────────────────
let termWs = null;
let termWsReconnectTimer = null;
let termWsConnected = false;
const xtermInstances = new Map(); // shellId → { term, fitAddon, ptyReady }
function getCookie(name) {
const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : '';
}
function termWsConnect() {
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url = `${proto}//${location.host}/ws`;
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
if (token) url += `?token=${encodeURIComponent(token)}`;
try { termWs = new WebSocket(url); } catch { return; }
termWs.onopen = () => {
termWsConnected = true;
// Spawn PTY for each existing shell
for (const [shellId, inst] of xtermInstances) {
if (!inst.ptyReady) {
inst.ptyReady = true;
const term = inst.term;
termWsSend({ type: 'pty_spawn', shellId, cols: term.cols, rows: term.rows });
}
}
};
termWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'pty_output') {
const inst = xtermInstances.get(msg.shellId);
if (inst) inst.term.write(msg.data);
} else if (msg.type === 'pty_exit') {
const inst = xtermInstances.get(msg.shellId);
if (inst) {
inst.ptyReady = false;
inst.term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
}
} else if (msg.type === 'pty_error') {
const inst = xtermInstances.get(msg.shellId);
if (inst) inst.term.write('\r\n\x1b[31mPTY error: ' + (msg.error || 'unknown') + '\x1b[0m\r\n');
}
};
termWs.onclose = () => {
termWsConnected = false;
termWs = null;
for (const [id, inst] of xtermInstances) inst.ptyReady = false;
termWsReconnectTimer = setTimeout(termWsConnect, 3000);
};
termWs.onerror = () => { termWsConnected = false; };
}
function termWsSend(msg) {
if (termWs && termWs.readyState === WebSocket.OPEN) {
termWs.send(JSON.stringify(msg));
return true;
}
return false;
}
function createXtermShell(container, shellId) {
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
// Create a sub-container for this shell's xterm instance
const sub = document.createElement('div');
sub.id = 'xterm-' + id;
sub.style.cssText = 'position:absolute;inset:0';
container.appendChild(sub);
const term = new Terminal({
theme: {
background: '#0C0C0C',
foreground: '#CCCCCC',
cursor: '#CCCCCC',
cursorAccent: '#0C0C0C',
selectionBackground: 'rgba(59,120,255,0.4)',
black: '#1e1e1e',
red: '#E74856',
green: '#16C60C',
yellow: '#C19C00',
blue: '#3B78FF',
magenta: '#B4009E',
cyan: '#61D6D6',
white: '#CCCCCC',
brightBlack: '#767676',
brightRed: '#FF6E6E',
brightGreen: '#69FF69',
brightYellow: '#FFFF55',
brightBlue: '#96CBFE',
brightMagenta: '#FF76F6',
brightCyan: '#8FFFFF',
brightWhite: '#FFFFFF',
},
fontFamily: "'IBM Plex Mono', 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.4,
cursorBlink: true,
scrollback: 5000,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
term.open(sub);
fitAddon.fit();
const inst = { term, fitAddon, ptyReady: false };
xtermInstances.set(id, inst);
// Send keystrokes to PTY
term.onData((data) => {
if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_input', shellId: id, data });
}
});
// Resize PTY when terminal resizes
term.onResize(({ cols, rows }) => {
if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_resize', shellId: id, cols, rows });
}
});
// Spawn PTY if WS is already connected
if (termWsConnected) {
inst.ptyReady = true;
termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows });
}
return { id, term, fitAddon, inst };
}
// ── Canvas interactive shell (multi-shell tabs) ─────────────────────────────
let termShells = [];
let activeTermShellId = null;
function createShell() {
const id = 'sh_' + Math.random().toString(36).slice(2, 8);
const shell = { id, name: 'bash', cwd: null, ptyReady: false, term: null, fitAddon: null };
termShells.push(shell);
return shell;
}
function getActiveShell() {
return termShells.find(s => s.id === activeTermShellId);
}
function renderShellTabs() {
const bar = document.getElementById('canvas-shell-tabs');
if (!bar) return;
bar.innerHTML = termShells.map(s => {
const active = s.id === activeTermShellId;
return `<div onclick="switchTermShell('${s.id}')" style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:13px;color:${active ? '#CCCCCC' : '#767676'};background:${active ? '#0C0C0C' : 'transparent'};border-top:${active ? '2px solid #3B78FF' : '2px solid transparent'};white-space:nowrap;user-select:none">
<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6;flex-shrink:0"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
<span>${escHtml(s.name)}</span>
${termShells.length > 1 ? `<span onclick="closeTermShell('${s.id}',event)" style="margin-left:3px;color:#767676;font-size:13px;line-height:1">&times;</span>` : ''}
</div>`;
}).join('');
}
function switchTermShell(id) {
// Hide current terminal sub-container
const cur = getActiveShell();
if (cur) {
const curSub = document.getElementById('xterm-' + cur.id);
if (curSub) curSub.style.display = 'none';
}
activeTermShellId = id;
const shell = getActiveShell();
if (!shell) return;
// Show or create xterm for this shell
if (shell.term) {
const sub = document.getElementById('xterm-' + shell.id);
if (sub) sub.style.display = '';
shell.term.focus();
if (shell.fitAddon) shell.fitAddon.fit();
} else {
const container = document.getElementById('canvas-term-container');
if (container) {
const result = createXtermShell(container, shell.id);
shell.term = result.term;
shell.fitAddon = result.fitAddon;
shell.ptyReady = result.inst.ptyReady;
}
}
renderShellTabs();
}
function addTermShell() {
const shell = createShell();
switchTermShell(shell.id);
}
function closeTermShell(id, ev) {
if (ev) { ev.stopPropagation(); }
const idx = termShells.findIndex(s => s.id === id);
if (idx === -1 || termShells.length <= 1) return;
termWsSend({ type: 'pty_kill', shellId: id });
const shell = termShells[idx];
if (shell.term) shell.term.dispose();
xtermInstances.delete(id);
// Remove sub-container
const sub = document.getElementById('xterm-' + id);
if (sub) sub.remove();
termShells.splice(idx, 1);
if (activeTermShellId === id) {
switchTermShell(termShells[Math.min(idx, termShells.length - 1)].id);
} else {
renderShellTabs();
}
}
// Fallback: HTTP POST for canvasRun and non-PTY scenarios
async function termExec(command) {
const shell = getActiveShell();
if (!shell || !command.trim()) return;
if (termWsConnected && shell.ptyReady && shell.term) {
termWsSend({ type: 'pty_input', shellId: shell.id, data: command + '\r' });
return;
}
const status = document.getElementById('canvas-run-status');
if (status) { status.textContent = '●'; status.style.color = '#C19C00'; }
try {
const resp = await fetch('/api/canvas/shell', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, cwd: shell.cwd }),
});
const data = await resp.json();
if (data.cwd) shell.cwd = data.cwd;
if (shell.term) {
if (data.stdout) shell.term.write(data.stdout);
if (data.stderr) shell.term.write('\x1b[31m' + data.stderr + '\x1b[0m');
}
if (status) { status.textContent = data.exitCode === 0 ? '✓' : '✗'; status.style.color = data.exitCode === 0 ? '#16C60C' : '#E74856'; }
} catch (err) {
if (shell.term) shell.term.write('\x1b[31mError: ' + String(err) + '\x1b[0m\r\n');
if (status) { status.textContent = '✗'; status.style.color = '#E74856'; }
}
}
function canvasRun() {
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!tab) return;
if (canvasEditor && tab.mode !== 'preview') tab.content = canvasEditor.getValue();
const ext = (tab.name.split('.').pop() || '').toLowerCase();
if (!['py','js','ts','mjs'].includes(ext)) return;
const runner = ext === 'py' ? 'python3' : (ext === 'ts' ? 'npx tsx' : 'node');
const tmpName = '.canvas-tmp/' + tab.name.replace(/[^a-zA-Z0-9._-]/g, '_');
termExec(`cat > "${tmpName}" << 'CANVAS_EOF'\n${tab.content}\nCANVAS_EOF && ${runner} "${tmpName}" && rm -f "${tmpName}"`);
}
// Init first shell + WS PTY connection
(function() {
const shell = createShell();
activeTermShellId = shell.id;
renderShellTabs();
fetch('/api/status').then(r => r.json()).then(d => {
if (d.workspace) shell.cwd = d.workspace;
termWsConnect();
}).catch(() => { termWsConnect(); });
})();
// Terminal drag-resize
(function() {
const drag = document.getElementById('canvas-term-drag');
const term = document.getElementById('canvas-terminal');
if (!drag || !term) return;
let startY, startH;
drag.addEventListener('mousedown', e => {
e.preventDefault();
startY = e.clientY;
startH = term.offsetHeight;
const onMove = ev => {
const dy = startY - ev.clientY;
const next = Math.max(32, Math.min(startH + dy, term.parentElement.offsetHeight - 38));
term.style.height = next + 'px';
const shell = getActiveShell();
if (shell && shell.fitAddon) shell.fitAddon.fit();
if (canvasEditor) canvasEditor.refresh();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
drag.style.background = '';
const shell = getActiveShell();
if (shell && shell.fitAddon) shell.fitAddon.fit();
};
drag.style.background = '#007acc';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
// Resize xterm when window resizes
window.addEventListener('resize', () => {
const shell = getActiveShell();
if (shell && shell.fitAddon) shell.fitAddon.fit();
});
function canvasInitDrop() {
const panel = document.getElementById('canvas-panel');
if (!panel) return;