diff --git a/package.json b/package.json index 18cd59d..9cfe85f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "2.8.2", + "version": "2.9.0", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/web-ui/code.html b/web-ui/code.html new file mode 100644 index 0000000..8c093de --- /dev/null +++ b/web-ui/code.html @@ -0,0 +1,222 @@ + + + + + +πŸ’» μ½”λ“œ 에디터 + + + + + + + + + + + + + + + + + + + + +
+ πŸ’» +

μ½”λ“œ 에디터

+ workspace/code/ +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+ + + +
+ + +
+
+ +
+
+
+
+
+ πŸ’» Coder AI +
+
+
μ½”λ“œλ₯Ό μ„ νƒν•˜κ³  "AIμ—κ²Œ 전솑"을 λˆ„λ₯΄κ±°λ‚˜, μ•„λž˜μ— μ§ˆλ¬Έμ„ μž…λ ₯ν•˜μ„Έμš”.
+
+
+ + +
+
+
+ + +
+ +
+ + + + + + + diff --git a/web-ui/code.js b/web-ui/code.js new file mode 100644 index 0000000..c3a8e0a --- /dev/null +++ b/web-ui/code.js @@ -0,0 +1,1253 @@ +// ============================================================================ +// 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(/(?:#|\/\/|--|\/\*|