// ============================================================================ // code.js β€” Standalone Code Editor page (code.html) // // This is a self-contained extract of the code-editor view that also lives // inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the // editor can run in its own browser tab (opened from the πŸ’» μ½”λ“œ sidebar tab), // mirroring how the slide wizard opens pptx-wizard.html. // // The main app keeps its own in-page code mode (for live code streaming from // the chat agent), so this file intentionally does not depend on app.js. // If you change code-view behaviour, update both this file and app.js. // ============================================================================ // ── Shared helpers (self-contained copies of app.js utilities) ────────────── function getAuthToken() { try { return sessionStorage.getItem('smallclaw_token') || ''; } catch { return ''; } } function setAuthToken(t) { try { t ? sessionStorage.setItem('smallclaw_token', t) : sessionStorage.removeItem('smallclaw_token'); } catch {} } function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra || {}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return ''; } function escHtml(s) { return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"'); } async function api(path, opts = {}) { const API = window.location.origin || 'http://127.0.0.1:18789'; const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts; const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders }); const r = await fetch(API + path, { headers, ...restOpts }); if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); } const ct = String(r.headers.get('content-type') || '').toLowerCase(); const bodyText = await r.text(); if (!ct.includes('application/json')) { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`); } let data = null; try { data = bodyText ? JSON.parse(bodyText) : null; } catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); } if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); } return data; } // ── Code Editor View ───────────────────────────────────────────────────────── let monacoEditor = null; let monacoReady = false; let codeFiles = []; // [{id, name, content, language, model}] let activeCodeFileId = null; let codeAiHistory = []; let codeAiAbort = null; let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10); let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud'; const CODE_LANG_MAP = { js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust', java:'java', cs:'csharp', cpp:'cpp', c:'c', php:'php', html:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', md:'markdown', sh:'shell', bash:'shell', sql:'sql', xml:'xml', toml:'ini', vue:'html', }; function codeDetectLang(name) { const ext = (name || '').split('.').pop().toLowerCase(); return CODE_LANG_MAP[ext] || 'plaintext'; } function updateCodeAiLabel() { const label = codeAiModel || 'AI'; const title = document.getElementById('code-ai-title'); if (title) title.textContent = 'πŸ’» Coder-' + label; const input = document.getElementById('code-ai-input'); if (input) input.placeholder = 'Coder-' + label + 'μ—κ²Œ 질문...'; } function initCodeView() { updateCodeAiLabel(); if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; } if (typeof require === 'undefined') return; require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } }); require(['vs/editor/editor.main'], function() { monacoReady = true; const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), { theme: isDark ? 'vs-dark' : 'vs', language: 'plaintext', automaticLayout: true, fontSize: 14, lineHeight: 22, fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace", minimap: { enabled: true }, scrollBeyondLastLine: false, wordWrap: 'on', renderWhitespace: 'selection', smoothScrolling: true, cursorBlinking: 'smooth', tabSize: 2, }); monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile); codeFlushPendingQueue(); if (!codeFiles.length && !codePendingQueue.length) codeNewFile('main.py', '# 여기에 μ½”λ“œλ₯Ό μž‘μ„±ν•˜μ„Έμš”\n'); else renderCodeFileTabs(); setupCodeVResize(); csbRestoreSettings(); setupCodeEditorDrop(); }); } function codeNewFile(name, content) { const fname = name || prompt('파일 이름:', 'untitled.py'); if (!fname || !fname.trim()) return; const id = 'cf_' + Math.random().toString(36).slice(2, 8); const lang = codeDetectLang(fname); const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null; codeFiles.push({ id, name: fname.trim(), content: content || '', language: lang, model }); switchCodeFile(id); } function switchCodeFile(id) { const prev = codeFiles.find(f => f.id === activeCodeFileId); if (prev && monacoEditor) prev.content = monacoEditor.getValue(); activeCodeFileId = id; const file = codeFiles.find(f => f.id === id); if (!file || !monacoEditor) { renderCodeFileTabs(); return; } if (!file.model) file.model = monaco.editor.createModel(file.content, file.language); monacoEditor.setModel(file.model); monacoEditor.focus(); renderCodeFileTabs(); codeUpdateRunButton(); } function closeCodeFile(id, ev) { if (ev) ev.stopPropagation(); const idx = codeFiles.findIndex(f => f.id === id); if (idx === -1) return; const file = codeFiles[idx]; if (file.model) file.model.dispose(); codeFiles.splice(idx, 1); if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; } if (activeCodeFileId === id) switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id); else renderCodeFileTabs(); } function renderCodeFileTabs() { const bar = document.getElementById('code-file-tabs'); if (!bar) return; bar.innerHTML = codeFiles.map(f => { const active = f.id === activeCodeFileId; return `
${escHtml(f.name)} ×
`; }).join(''); } const CODE_DIR = 'code'; let codeDirLabel = 'code'; function csbCodeDirChanged(val) { /* preview only, saved on button click */ } async function csbCodeDirSave() { const input = document.getElementById('csb-codedir'); const val = (input?.value || '').trim() || 'code'; try { await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ code_dir: val }) }); codeDirLabel = val; const folderBtn = document.getElementById('code-folder-btn'); if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${val}/`; const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]'); if (headerLabel) headerLabel.textContent = `workspace/${val}/`; codeSbRefresh(); } catch {} } async function csbLoadCodeDir() { try { const data = await api('/api/settings/paths'); const dir = data.code_dir || 'code'; codeDirLabel = dir; const input = document.getElementById('csb-codedir'); if (input) input.value = dir; const folderBtn = document.getElementById('code-folder-btn'); if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${dir}/`; const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]'); if (headerLabel) headerLabel.textContent = `workspace/${dir}/`; } catch {} } function codeSaveFile() { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; file.content = monacoEditor.getValue(); api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }), }) .then(r => { if (r.success) codeAiAppend('system', `πŸ’Ύ μ €μž₯됨: ${r.path}`); else throw new Error(r.error); }) .catch(e => codeAiAppend('system', `⚠️ μ €μž₯ μ‹€νŒ¨: ${e.message || e}`)); } // ── Terminal infrastructure (PTY over WebSocket) ───────────────────────── let termWs = null; let termWsConnected = false; 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; for (const [shellId, inst] of xtermInstances) { if (!inst.ptyReady) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); } } }; termWs.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write(msg.data); } else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); } } else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); } }; termWs.onclose = () => { termWsConnected = false; termWs = null; for (const [,i] of xtermInstances) i.ptyReady = false; 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; } const xtermInstances = new Map(); function createXtermShell(container, shellId) { const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8); 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:'#22c55e', selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C', yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC', brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' }, fontFamily: "'IBM Plex Mono','Courier New',monospace", fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000, }); const fitAddon = new FitAddon.FitAddon(); const webLinksAddon = new WebLinksAddon.WebLinksAddon(); term.loadAddon(fitAddon); term.loadAddon(webLinksAddon); term.open(sub); fitAddon.fit(); const inst = { term, fitAddon, ptyReady: false }; xtermInstances.set(id, inst); term.onData(data => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data }); }); term.onResize(({ cols, rows }) => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); }); if (termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); } return { id, term, fitAddon, inst }; } // Init WebSocket connection on load fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect); // ── Chat terminal (for chat view) ───────────────────────────────────────── let chatTermShells = []; let activeChatTermShellId = null; let chatTermOpen = false; let chatTermHeight = 280; function toggleChatTerminal() { const panel = document.getElementById('chat-terminal-panel'); const btn = document.getElementById('chat-term-btn'); if (!panel) return; chatTermOpen = !chatTermOpen; if (chatTermOpen) { panel.style.display = 'flex'; if (btn) btn.classList.add('active'); if (!chatTermShells.length) { addChatTermShell(); } else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); } } else { panel.style.display = 'none'; if (btn) btn.classList.remove('active'); } } function renderChatShellTabs() { const bar = document.getElementById('chat-shell-tabs'); if (!bar) return; bar.innerHTML = chatTermShells.map(s => { const a = s.id === activeChatTermShellId; return `
${escHtml(s.name)}${chatTermShells.length>1?`×`:''}
`; }).join(''); } function addChatTermShell() { const id = 'ct_' + Math.random().toString(36).slice(2,8); chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null }); switchChatTermShell(id); } function switchChatTermShell(id) { const cur = chatTermShells.find(s => s.id === activeChatTermShellId); if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; } activeChatTermShellId = id; const shell = chatTermShells.find(s => s.id === id); if (!shell) return; if (shell.term) { const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display=''; if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus(); } else { const container = document.getElementById('chat-term-container'); if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); } } renderChatShellTabs(); } function closeChatTermShell(id, ev) { if (ev) ev.stopPropagation(); const idx = chatTermShells.findIndex(s => s.id === id); if (idx === -1 || chatTermShells.length <= 1) return; termWsSend({ type:'pty_kill', shellId:id }); const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose(); xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove(); chatTermShells.splice(idx, 1); if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id); else renderChatShellTabs(); } document.addEventListener('DOMContentLoaded', function() { const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return; let startY=0, startH=0; handle.addEventListener('mousedown', function(e) { startY=e.clientY; startH=chatTermHeight; const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); }); }); // ── Code editor terminal ────────────────────────────────────────────────── let codeTermShells = []; let activeCodeTermShellId = null; let codeTermOpen = false; function toggleCodeTerminal() { const panel = document.getElementById('code-term-panel'); const btn = document.getElementById('code-term-toggle-btn'); if (!panel) return; codeTermOpen = !codeTermOpen; if (codeTermOpen) { panel.style.display = 'flex'; if (btn) btn.style.color = '#22c55e'; if (!codeTermShells.length) { addCodeTermShell(); } else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); } } else { panel.style.display = 'none'; if (btn) btn.style.color = '#6fcf8a'; } if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } function renderCodeTermTabs() { const bar = document.getElementById('code-term-tabs'); if (!bar) return; bar.innerHTML = codeTermShells.map(s => { const a = s.id === activeCodeTermShellId; return `
bash${codeTermShells.length>1?` ×`:''}
`; }).join(''); } function addCodeTermShell() { const id = 'cdt_' + Math.random().toString(36).slice(2,8); codeTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null }); switchCodeTermShell(id); } function switchCodeTermShell(id) { const cur = codeTermShells.find(s => s.id === activeCodeTermShellId); if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; } activeCodeTermShellId = id; const shell = codeTermShells.find(s => s.id === id); if (!shell) return; if (shell.term) { const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display=''; if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus(); } else { const container = document.getElementById('code-term-container'); if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); } } renderCodeTermTabs(); } function closeCodeTermShell(id, ev) { if (ev) ev.stopPropagation(); const idx = codeTermShells.findIndex(s => s.id === id); if (idx === -1 || codeTermShells.length <= 1) return; termWsSend({ type:'pty_kill', shellId:id }); const shell = codeTermShells[idx]; if (shell.term) shell.term.dispose(); xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove(); codeTermShells.splice(idx, 1); if (activeCodeTermShellId === id) switchCodeTermShell(codeTermShells[Math.min(idx,codeTermShells.length-1)].id); else renderCodeTermTabs(); } // Resize drag (top edge β†’ pull up/down) document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-term-drag'); const panel = document.getElementById('code-term-panel'); if (!drag || !panel) return; let startY=0, startH=0; drag.addEventListener('mousedown', e => { startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); const onMove = e2 => { const dy = startY - e2.clientY; panel.style.height = Math.max(80, Math.min(window.innerHeight*.6, startH+dy)) + 'px'; const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; }; drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); // Restore log panel collapsed state (function() { if (localStorage.getItem('logPanelCollapsed') === '1') { const panel = document.getElementById('sidebar-log-panel'); if (panel) panel.classList.add('collapsed'); } })(); // Ctrl+` shortcut document.addEventListener('keydown', e => { if (e.ctrlKey && e.key === '`') { e.preventDefault(); if (document.getElementById('code-view')?.classList.contains('active')) toggleCodeTerminal(); } }); // ── Code Run: multi-mode execution ────────────────────────────────────────── const CODE_RUN_MODES = { html: [ { id: 'browser', icon: '🌐', label: 'λΈŒλΌμš°μ €', desc: 'μƒˆ νƒ­μ—μ„œ μ—΄κΈ°' }, { id: 'terminal', icon: '⌨', label: '터미널', desc: 'μ›Ή μ„œλ²„λ‘œ μ‹€ν–‰' }, ], htm: 'html', py: [ { id: 'pyodide', icon: '🐍', label: 'Pyodide', desc: 'λΈŒλΌμš°μ €μ—μ„œ μ‹€ν–‰' }, { id: 'terminal', icon: '⌨', label: 'python3', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], js: [ { id: 'terminal', icon: '⌨', label: 'node', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], ts: [ { id: 'terminal', icon: '⌨', label: 'ts-node', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], go: [ { id: 'terminal', icon: '⌨', label: 'go run', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], rs: [ { id: 'terminal', icon: '⌨', label: 'rustc', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], cpp: [ { id: 'terminal', icon: '⌨', label: 'g++', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], c: [ { id: 'terminal', icon: '⌨', label: 'gcc', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], sh: [ { id: 'terminal', icon: '⌨', label: 'bash', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], }; function codeGetRunModes(ext) { const modes = CODE_RUN_MODES[ext]; if (!modes) return [{ id: 'terminal', icon: '⌨', label: '터미널', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }]; if (typeof modes === 'string') return CODE_RUN_MODES[modes]; return modes; } let _codeRunMenuOpen = false; function codeToggleRunMenu() { const menu = document.getElementById('code-run-menu'); if (_codeRunMenuOpen) { menu.style.display = 'none'; _codeRunMenuOpen = false; return; } const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file) return; const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); menu.innerHTML = modes.map(m => `
` + `${m.icon}` + `
${m.label}
${m.desc}
` + `
` ).join(''); menu.style.display = 'block'; _codeRunMenuOpen = true; const close = (e) => { if (!menu.contains(e.target) && !e.target.closest('#code-run-dropdown')) { menu.style.display = 'none'; _codeRunMenuOpen = false; document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } function codeUpdateRunButton() { const file = codeFiles.find(f => f.id === activeCodeFileId); const iconEl = document.getElementById('code-run-icon'); const labelEl = document.getElementById('code-run-label'); if (!file || !iconEl) return; const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); const primary = modes[0]; iconEl.textContent = primary.icon; labelEl.textContent = primary.label; } function codeRunDefault() { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; file.content = monacoEditor.getValue(); const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); codeRunWith(modes[0].id); } function codeRunWith(mode) { const menu = document.getElementById('code-run-menu'); if (menu) menu.style.display = 'none'; _codeRunMenuOpen = false; const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; file.content = monacoEditor.getValue(); const fpath = `${CODE_DIR}/${file.name}`; if (mode === 'browser') { api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }) }) .then(r => { if (r.success) { const w = window.open(`${location.origin}/code-serve/${CODE_DIR}/${file.name}`, '_blank'); if (!w) codeAiAppend('system', '⚠️ νŒμ—… 차단됨 β€” λΈŒλΌμš°μ €μ—μ„œ νŒμ—…μ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”'); } }); return; } if (mode === 'pyodide') { sessionStorage.setItem('py_run_code', file.content); sessionStorage.setItem('py_run_name', file.name); const w = window.open('/python-runner.html', '_blank'); if (!w) codeAiAppend('system', '⚠️ νŒμ—… 차단됨 β€” λΈŒλΌμš°μ €μ—μ„œ νŒμ—…μ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”'); return; } // terminal modes const ext = file.name.split('.').pop().toLowerCase(); let cmd; if (mode === 'terminal') { cmd = ext === 'py' ? `python3 ${fpath}` : ext === 'js' ? `node ${fpath}` : ext === 'ts' ? `npx ts-node ${fpath}` : ext === 'go' ? `go run ${fpath}` : ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'sh' ? `bash ${fpath}` : `./${fpath}`; } else { cmd = mode === 'python3' ? `python3 ${fpath}` : mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`; } if (!codeTermOpen) toggleCodeTerminal(); setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: s.id, data: cmd + '\r' }); }, codeTermOpen ? 0 : 500); } function codeSendSelectionToAI() { if (!monacoEditor) return; const sel = monacoEditor.getSelection(); const selected = sel && !sel.isEmpty() ? monacoEditor.getModel().getValueInRange(sel) : monacoEditor.getValue(); const file = codeFiles.find(f => f.id === activeCodeFileId); const lang = file ? file.language : 'code'; const prompt = `λ‹€μŒ ${lang} μ½”λ“œλ₯Ό λΆ„μ„ν•΄μ€˜:\n\`\`\`${lang}\n${selected}\n\`\`\``; document.getElementById('code-ai-input').value = prompt; codeAiSend(); } // ─── V2 Chat Live Code Streaming ──────────────────────────────────────────────── // Global state for streaming code into the editor during the main v2 chat. let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false; let codePendingQueue = []; // [{fname, content, lang}] β€” queued when Monaco not ready // Throttled Monaco model update β€” avoids freezing UI by limiting setValue calls to ~80ms intervals let _monacoThrottleTimer = null; let _monacoThrottleModel = null; let _monacoThrottleValue = ''; function codeMonacoThrottle(model, value) { _monacoThrottleModel = model; _monacoThrottleValue = value; if (_monacoThrottleTimer) return; // already scheduled _monacoThrottleTimer = setTimeout(() => { _monacoThrottleTimer = null; if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { _monacoThrottleModel.setValue(_monacoThrottleValue); _monacoThrottleModel = null; } }, 80); } function codeMonacoThrottleFlush() { if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; } if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { _monacoThrottleModel.setValue(_monacoThrottleValue); } _monacoThrottleModel = null; } function codeFlushPendingQueue() { if (!monacoReady || !monacoEditor || !codePendingQueue.length) return; for (const item of codePendingQueue) { const existing = codeFiles.find(f => f.name === item.fname); if (existing) { if (existing.model) existing.model.setValue(item.content); else existing.content = item.content; } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname)); codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model }); if (!v2LiveFileId) v2LiveFileId = id; } } codePendingQueue = []; if (v2LiveFileId) switchCodeFile(v2LiveFileId); } function v2LiveCodeUpdate(fullReply) { if (typeof codeIsOpenable !== 'function') return; const fenceRe = /```([\w\-]*)\s*\n/g; let lastOpen = null, m; while ((m = fenceRe.exec(fullReply)) !== null) { if (lastOpen === null) { lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length }; } else { lastOpen = null; } } if (!lastOpen) { v2LiveInBlock = false; return; } const code = fullReply.slice(lastOpen.start); const lang = lastOpen.lang; if (!v2LiveInBlock) { if (!codeIsOpenable(code, lang)) return; v2LiveInBlock = true; v2LiveLang = lang; const fname = codeDeriveName(code, lang); if (monacoReady && monacoEditor) { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(code, codeDetectLang(fname)); codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model }); v2LiveFileId = id; switchCodeFile(id); } else { // Monaco not ready yet β€” queue and trigger init codePendingQueue = [{ fname, content: code, lang }]; initCodeView(); } } else if (v2LiveFileId) { const file = codeFiles.find(f => f.id === v2LiveFileId); if (file?.model) { codeMonacoThrottle(file.model, code); } } else if (codePendingQueue.length) { // Monaco still loading β€” update queued content codePendingQueue[codePendingQueue.length - 1].content = code; } } async function codeAiSend() { const input = document.getElementById('code-ai-input'); const message = input.value.trim(); if (!message) return; input.value = ''; input.style.height = ''; codeAiAppend('user', message); codeAiHistory.push({ role: 'user', content: message }); const thinkEl = codeAiAppend('thinking', '생각 쀑...'); try { if (codeAiAbort) codeAiAbort.abort(); codeAiAbort = new AbortController(); const historyForAPI = codeAiHistory.slice(-10, -1).map(m => ({ role: m.role, content: m.content })); const res = await fetch('/api/chat', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }), signal: codeAiAbort.signal, body: JSON.stringify({ message, history: historyForAPI, useTools: true, sessionId: codeAiSessionId, ...(codeAiModel ? { model: codeAiModel } : {}) }), }); if (!res.ok) throw new Error('HTTP ' + res.status); const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = '', reply = ''; thinkEl.textContent = ''; thinkEl.className = 'code-ai-msg assistant'; // Live streaming state let liveFileId = null, liveLang = '', liveCodeBuf = '', liveInBlock = false; function codeAiLiveUpdate(fullReply) { // Find the last unclosed code block const fenceRe = /```([\w\-]*)\s*\n/g; let lastOpen = null, lastClose = -1, m; let pos = 0; while ((m = fenceRe.exec(fullReply)) !== null) { if (lastOpen === null) { lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length }; } else { lastClose = m.index; lastOpen = null; } } if (!lastOpen) { liveInBlock = false; return; } // We're inside an open code block const code = fullReply.slice(lastOpen.start); const lang = lastOpen.lang; if (!liveInBlock) { // Wait until the block looks like a real file (not a short run-command snippet) if (!codeIsOpenable(code, lang)) return; liveInBlock = true; liveLang = lang; const fname = codeDeriveName(code, lang); if (monacoReady && monacoEditor) { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(code, codeDetectLang(fname)); codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model }); liveFileId = id; switchCodeFile(id); } else { codePendingQueue = [{ fname, content: code, lang }]; initCodeView(); } } else if (liveFileId) { const file = codeFiles.find(f => f.id === liveFileId); if (file?.model) { liveCodeBuf = code; codeMonacoThrottle(file.model, code); } } else if (codePendingQueue.length) { codePendingQueue[codePendingQueue.length - 1].content = code; } } while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (data === '[DONE]') continue; try { const ev = JSON.parse(data); let token = null; if (ev.type === 'token' && (ev.content || ev.text)) token = ev.content || ev.text; else if (ev.type === 'text_delta' && ev.delta) token = ev.delta; else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; } else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; } // Handle tool_call / tool_result events for live editor update else if (ev.type === 'tool_call' && ev.action === 'create_file') { const cf = ev.args?.filename || ev.args?.name || ''; const cc = ev.args?.content || ''; if (cf && cc) codeEditorWriteFile(cf.replace(/^code\//, ''), cc); } else if (ev.type === 'tool_result' && ev.action === 'create_file' && !ev.error) { codeSbRefresh(); } if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); codeAiLiveUpdate(reply); } } catch {} } } liveInBlock = false; liveFileId = null; codeMonacoThrottleFlush(); codeAiHistory.push({ role: 'assistant', content: reply || '(응닡 μ—†μŒ)' }); if (!reply) { thinkEl.textContent = '(응닡 μ—†μŒ)'; } else codeAiRenderMarkdown(thinkEl, reply); } catch(e) { thinkEl.className = 'code-ai-msg thinking'; thinkEl.textContent = '였λ₯˜: ' + (e.message || e); } } function codeAiAppend(role, text) { const msgs = document.getElementById('code-ai-messages'); if (!msgs) return null; const placeholder = msgs.querySelector('.code-ai-msg.thinking'); if (placeholder && placeholder.textContent.includes('AIμ—κ²Œ 전솑')) placeholder.remove(); const el = document.createElement('div'); el.className = 'code-ai-msg ' + role; el.textContent = text; msgs.appendChild(el); codeAiScrollBottom(); return el; } // Render AI message with markdown code blocks parsed out function codeAiRenderMarkdown(el, text) { el.innerHTML = ''; const parts = text.split(/(```[\w\-]*[ \t]*\r?\n[\s\S]*?```)/g); let codeCount = 0; const codeBlocks = []; for (const part of parts) { const m = part.match(/^```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```\s*$/); if (m) { const lang = m[1] || 'text'; const code = m[2]; codeBlocks.push({ lang, code }); const wrapper = document.createElement('div'); wrapper.className = 'code-ai-code-block'; const header = document.createElement('div'); header.className = 'code-ai-code-header'; const langLabel = document.createElement('span'); langLabel.textContent = lang || 'code'; const openBtn = document.createElement('button'); openBtn.className = 'code-ai-open-btn'; openBtn.textContent = 'πŸ“‚ μ—λ””ν„°μ—μ„œ μ—΄κΈ°'; openBtn.onclick = () => codeAiOpenInEditor(code, lang); header.appendChild(langLabel); header.appendChild(openBtn); const pre = document.createElement('pre'); pre.textContent = code; wrapper.appendChild(header); wrapper.appendChild(pre); el.appendChild(wrapper); codeCount++; } else if (part) { const span = document.createElement('span'); span.style.whiteSpace = 'pre-wrap'; span.textContent = part; el.appendChild(span); } } // Auto-open all code blocks in editor if (codeBlocks.length) codeBlocks.forEach(b => codeAiOpenInEditor(b.code, b.lang)); } const CODE_EXT_MAP = { // JavaScript / Node javascript:'js', js:'js', node:'js', nodejs:'js', typescript:'ts', ts:'ts', jsx:'jsx', tsx:'tsx', // Python python:'py', py:'py', python3:'py', // C family c:'c', 'c++':'cpp', cpp:'cpp', 'c#':'cs', csharp:'cs', cs:'cs', // JVM java:'java', kotlin:'kt', kt:'kt', scala:'scala', groovy:'groovy', // Systems / compiled rust:'rs', rs:'rs', go:'go', golang:'go', zig:'zig', // Web html:'html', css:'css', scss:'scss', less:'less', vue:'vue', svelte:'svelte', // Data / Config json:'json', yaml:'yml', yml:'yml', toml:'toml', xml:'xml', csv:'csv', // Shell shell:'sh', bash:'sh', sh:'sh', zsh:'sh', fish:'fish', powershell:'ps1', ps1:'ps1', // Database sql:'sql', sqlite:'sql', postgresql:'sql', mysql:'sql', // Mobile / Other swift:'swift', dart:'dart', lua:'lua', perl:'pl', r:'r', ruby:'rb', rb:'rb', php:'php', elixir:'ex', erlang:'erl', haskell:'hs', ocaml:'ml', fsharp:'fs', // Docs markdown:'md', md:'md', tex:'tex', latex:'tex', }; // Decide whether a markdown code block is a real file worth opening in the editor. // Filters out shell run-instructions (e.g. ```python code/tetris.py```) and tiny snippets. function codeIsOpenable(code, lang) { const trimmed = (code || '').trim(); if (!trimmed) return false; const lines = trimmed.split('\n').filter(l => l.trim()); const l = (lang || '').toLowerCase(); const isShellLang = ['', 'text', 'console', 'sh', 'bash', 'shell', 'zsh', 'powershell', 'ps1'].includes(l); const cmdRe = /^\s*[$#>]?\s*(python3?|pip3?|npm|npx|node|yarn|pnpm|deno|bun|cd|ls|dir|cat|echo|curl|wget|git|chmod|mkdir|rm|cp|mv|source|sudo|export|set|run|\.\/)\b/i; // Single line: skip shell langs and short command-looking lines if (lines.length <= 1) { if (isShellLang) return false; if (cmdRe.test(trimmed)) return false; if (trimmed.length < 40) return false; } // Short shell block where every line is a command β†’ run instructions, not a file if (isShellLang && lines.length <= 3 && lines.every(ln => cmdRe.test(ln))) return false; return true; } // Derive a filename from an embedded `# filename:` hint, else a random one. function codeDeriveName(code, lang) { const m = (code || '').match(/(?:#|\/\/|--|\/\*|