diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 985f7c3..968fdfa 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -3182,7 +3182,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi case 'coder_str_replace': return { name, args, result: `Updated ${fname}.`, error: false }; case 'coder_delete_file': return { name, args, result: `Deleted ${fname}.`, error: false }; case 'coder_move_file': return { name, args, result: `${args.source || fname} → ${args.destination || '(destination)'}`, error: false }; - case 'coder_read_file': return { name, args, result: `"${fname}"의 내용은 이미 대화 컨텍스트에 줄 번호와 함께 포함되어 있습니다. 컨텍스트의 해당 내용을 그대로 사용하세요. 컨텍스트에 없다면 사용자에게 에디터에서 파일을 열어달라고 요청하세요.`, error: false }; + case 'coder_read_file': return { name, args, result: `[LOCAL-FILE-CONTENT-PENDING] 클라이언트가 "${fname}" 내용을 주입 중입니다. 컨텍스트의 [파일: ${fname}] 블록을 확인하세요. 없다면 컨텍스트에 있는 다른 파일을 바탕으로 작업을 계속 진행하세요. 사용자에게 파일을 열어달라고 요청하지 마세요.`, error: false }; case 'coder_list_files': case 'search_files': return { name, args, result: `프로젝트 파일과 내용은 이미 대화 컨텍스트의 [파일: ...] 블록에 포함되어 있습니다. 별도 조회 없이 컨텍스트를 참고하세요.`, error: false }; // Shell/run tools see the server workspace, not the client's local folder. @@ -11408,6 +11408,61 @@ app.post('/api/chat', async (req, res) => { } }); +// Lightweight one-round endpoint for client-driven code editor loop. +// Calls Ollama once, streams tokens as SSE, returns tool_calls without executing them. +app.post('/api/code/round', async (req, res) => { + const { messages, tools, model: reqModel, sessionId = 'code_rnd' } = req.body; + if (!Array.isArray(messages) || !messages.length) { + res.status(400).json({ error: 'messages required' }); return; + } + const modelOverride = typeof reqModel === 'string' ? reqModel : undefined; + const ollama = getOllamaClient(); + + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + const sendSSE = createSSESender(res); + const heartbeat = setInterval(() => sendSSE('heartbeat', { state: 'processing' }), 5000); + isModelBusy = true; + isModelBusySince = Date.now(); + const abortSignal = { aborted: false }; + res.on('close', () => { abortSignal.aborted = true; }); + + try { + let streamedText = ''; + const onToken = (t: string) => { streamedText += t; try { sendSSE('token', { text: t }); } catch {} }; + const result = await ollama.chatWithThinkingStream(messages, 'executor', onToken, { + tools: tools || [], + temperature: 0.3, + num_predict: 32768, + think: true, + model: modelOverride, + }); + const rawToolCalls: any[] = result.message?.tool_calls || []; + const normalizedToolCalls = rawToolCalls.map((tc: any, i: number) => ({ + id: tc.id || `call_${(tc.function?.name || 'tool').replace(/\W/g, '_')}_${i}_${Date.now()}`, + name: tc.function?.name || '', + args: normalizeToolArgs(tc.function?.arguments), + })); + if (result.usage) sendSSE('usage', result.usage); + sendSSE('done', { + reply: result.message?.content || streamedText, + toolCalls: normalizedToolCalls, + thinking: result.thinking, + usage: result.usage, + }); + } catch (err: any) { + if (!abortSignal.aborted) { console.error('[code/round]', err?.message || err); sendSSE('error', { message: err.message || 'Unknown error' }); } + } finally { + clearInterval(heartbeat); + isModelBusy = false; + isModelBusySince = 0; + res.end(); + } +}); + app.get('/api/open-path', async (req, res) => { const fp = req.query.path as string; if (!fp) { res.status(400).json({ error: 'Path required' }); return; } @@ -14080,6 +14135,74 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N res.status(500).json({ error: String(err?.message || err) }); }); +// ── Arduino compile endpoint ─────────────────────────────────────────────── +app.post('/api/arduino/compile', async (req, res) => { + const sess = getSessionUser(req); + if (!sess) return res.status(401).json({ error: 'Not authenticated' }); + const { code, board = 'uno' } = req.body as { code?: string; board?: string }; + if (!code) return res.status(400).json({ error: 'code is required' }); + + const isESP32 = board.startsWith('esp32'); + const fqbn = isESP32 ? 'esp32:esp32:esp32' + : board === 'nano' ? 'arduino:avr:nano:cpu=atmega328old' + : 'arduino:avr:uno'; + + // Check arduino-cli — also check ~/.local/bin where user installs + const cliPath = await new Promise((resolve) => { + const candidates = [ + process.env.HOME + '/.local/bin/arduino-cli', + '/usr/local/bin/arduino-cli', + '/usr/bin/arduino-cli', + ]; + require('child_process').exec('which arduino-cli', { env: { ...process.env, PATH: process.env.PATH + ':' + (process.env.HOME || '') + '/.local/bin' } }, (err: any, stdout: string) => { + if (!err && stdout.trim()) { resolve(stdout.trim()); return; } + const found = candidates.find(p => { try { return require('fs').existsSync(p); } catch { return false; } }); + resolve(found || ''); + }); + }); + + if (!cliPath) { + return res.status(503).json({ + error: 'arduino-cli가 설치되어 있지 않습니다.', + install: 'curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | BINDIR=~/.local/bin sh', + installCore: 'arduino-cli core install arduino:avr esp32:esp32', + }); + } + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'arduino-')); + const sketchName = 'sketch'; + const sketchDir = path.join(tmpDir, sketchName); + fs.mkdirSync(sketchDir); + fs.writeFileSync(path.join(sketchDir, `${sketchName}.ino`), code); + + try { + const result = await new Promise<{ hex?: string; merged?: string; size?: number; error?: string }>((resolve) => { + require('child_process').exec( + `"${cliPath}" compile --fqbn ${fqbn} --output-dir ${tmpDir} ${sketchDir}`, + { timeout: 120000 }, + (err: any, stdout: string, stderr: string) => { + if (err) { resolve({ error: stderr || stdout || String(err) }); return; } + const files = fs.readdirSync(tmpDir); + if (isESP32) { + // Return merged.bin (bootloader + partitions + app, flash at 0x0) + const mergedFile = files.find((f: string) => f.endsWith('.merged.bin')); + if (!mergedFile) { resolve({ error: 'ESP32 컴파일 완료 but merged.bin 없음\n' + stdout }); return; } + const buf = fs.readFileSync(path.join(tmpDir, mergedFile)); + resolve({ merged: buf.toString('base64'), size: buf.length }); + } else { + const hexFile = files.find((f: string) => f.endsWith('.hex')); + if (!hexFile) { resolve({ error: '컴파일 완료 but .hex 파일 없음\n' + stdout }); return; } + resolve({ hex: fs.readFileSync(path.join(tmpDir, hexFile), 'utf-8') }); + } + } + ); + }); + res.json(result); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + app.get('/{*path}', (_req, res) => { res.sendFile(path.join(webUiPath, 'index.html')); }); diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html new file mode 100644 index 0000000..63b4bfd --- /dev/null +++ b/web-ui/arduino-emulator.html @@ -0,0 +1,469 @@ + + + + + +Arduino Emulator + + + + +
+

🔌 Arduino Emulator

+ sketch.ino + 컴파일 중... +
+ 속도 + + + + +
+
+ +
+ +
+
ARDUINO UNO · ATmega328P
+
+ + + + + + + + ATmega328P + + DIGITAL + + ANALOG / POWER + + + L + +
+
+
DIGITAL PINS
+
+
+
ANALOG PINS
+
+
+
+ + +
+
+ SERIAL MONITOR + 9600 baud + +
+
+ 에뮬레이터 준비 중... +
+
+ + +
+
+
+ + + + +
+
+
+

컴파일 중...

+ + +
+
+ + + + + diff --git a/web-ui/code.html b/web-ui/code.html index c46ba4b..684deac 100644 --- a/web-ui/code.html +++ b/web-ui/code.html @@ -381,10 +381,15 @@ -
- + onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();codeAiSend();}" + onpaste="_codeAiPasteImage(event)">
diff --git a/web-ui/code.js b/web-ui/code.js index 3147ecc..05e7284 100644 --- a/web-ui/code.js +++ b/web-ui/code.js @@ -84,6 +84,7 @@ let _prevTurnDoneTools = []; // legacy: kept for session-clear compatibility let _readFileCache = {}; // { bareFilename: fullContent } — persists across turns so model remembers Read files let _historySummary = ''; // compressed summary of old turns injected into system prompt let _karpathyMode = localStorage.getItem('codeKarpathyMode') === '1'; // Andrej Karpathy coding guidelines +let _pendingImages = []; // { dataUrl: string, mimeType: string }[] let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10); let activeProjectName = localStorage.getItem('code_active_project') || null; let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud'; @@ -453,6 +454,13 @@ async function _clientLoadProjectFiles() { renderCodeFileTabs(); // Switch to first file if any if (codeFiles.length) switchCodeFile(codeFiles[0].id); + + // Inject file manifest into empty AI history so AI knows existing files + if (codeFiles.length > 0 && codeAiHistory.length === 0) { + const manifest = codeFiles.map(f => f.name).join(', '); + codeAiHistory.push({ role: 'user', content: `[기존 프로젝트 파일: ${manifest}]\n이 파일들이 이미 존재합니다. 새로 만들지 마세요.` }); + codeAiHistory.push({ role: 'assistant', content: '기존 프로젝트 파일 목록을 확인했습니다.' }); + } } // ── Terminal output capture (for AI error feedback loop) ───────────────── @@ -1087,6 +1095,11 @@ const CODE_RUN_MODES = { sh: [ { id: 'terminal', icon: '⌨', label: 'bash', desc: '터미널에서 실행' }, ], + ino: [ + { id: 'arduino', icon: '🔌', label: 'Arduino 에뮬레이터', desc: 'AVR 브라우저 에뮬레이터 (avr8js)' }, + { id: 'esp32', icon: '⚡', label: 'ESP32 플래시', desc: 'Web Serial로 ESP32에 업로드' }, + { id: 'terminal', icon: '⌨', label: 'arduino-cli', desc: '터미널에서 빌드/업로드' }, + ], }; function codeGetRunModes(ext) { @@ -1272,6 +1285,49 @@ function codeRunWith(mode) { return; } + if (mode === 'esp32') { + codeAiAppend('system', `⚡ ESP32 컴파일 중... (${file.name})`); + fetch('/api/arduino/compile', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders({}) }, + body: JSON.stringify({ code: file.content, board: 'esp32' }), + }).then(r => r.json()).then(data => { + if (data.error) { + codeAiAppend('system', '⚠️ 컴파일 오류:\n' + data.error); + return; + } + sessionStorage.setItem('arduino_merged', data.merged); + sessionStorage.setItem('arduino_filename', file.name); + const w = window.open('/esp32-flasher.html', '_blank'); + if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); + else codeAiAppend('system', `✅ 컴파일 완료 (${(data.merged.length * 3/4 / 1024).toFixed(0)} KB). ESP32 Flasher 창에서 플래시하세요.`); + }).catch(e => codeAiAppend('system', '⚠️ 서버 오류: ' + e.message)); + return; + } + + if (mode === 'arduino') { + const board = 'uno'; + codeAiAppend('system', `🔌 컴파일 중... (${file.name})`); + fetch('/api/arduino/compile', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders({}) }, + body: JSON.stringify({ code: file.content, board }), + }).then(r => r.json()).then(data => { + if (data.error) { + let msg = '⚠️ 컴파일 오류:\n' + data.error; + if (data.install) msg += '\n\n설치 필요:\n' + data.install; + codeAiAppend('system', msg); + return; + } + sessionStorage.setItem('arduino_hex', data.hex); + sessionStorage.setItem('arduino_filename', file.name); + sessionStorage.setItem('arduino_board', board); + const w = window.open('/arduino-emulator.html', '_blank'); + if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); + }).catch(e => codeAiAppend('system', '⚠️ 서버 오류: ' + e.message)); + return; + } + // terminal modes const ext = file.name.split('.').pop().toLowerCase(); @@ -1856,8 +1912,11 @@ function _setSendBtnBusy(busy) { async function _codeAiSendImpl() { const input = document.getElementById('code-ai-input'); const message = input.value.trim(); - if (!message) return; + const attachedImages = [..._pendingImages]; + if (!message && !attachedImages.length) return; input.value = ''; input.style.height = ''; + _pendingImages = []; + _codeAiRenderImagePreview(); // @파일명 참조 처리 — 메시지에서 @xxx.js 패턴을 찾아 파일 내용 삽입 let resolvedMessage = message; @@ -2008,7 +2067,16 @@ async function _codeAiSendImpl() { } } - codeAiAppend('user', message); + // 이미지 첨부 시 채팅 버블에 썸네일 표시 + const userBubble = codeAiAppend('user', message || '🖼️'); + if (attachedImages.length) { + attachedImages.forEach(img => { + const im = document.createElement('img'); + im.src = img.dataUrl; + im.style.cssText = 'max-width:120px;max-height:90px;border-radius:4px;display:block;margin-top:4px'; + userBubble.appendChild(im); + }); + } const historyEntry = currentFile ? `[파일: ${_clientBareFileName(currentFile.name)}] ${message}` : message; codeAiHistory.push({ role: 'user', content: historyEntry }); try { updateSessionPanel(); } catch (_) {} @@ -2051,10 +2119,17 @@ async function _codeAiSendImpl() { // Build messages for client-owned loop (Claude Code style) const _statusMove = (text) => { thinkEl.dataset.status = text; thinkEl.textContent = `${text} ${_execSecs}s`; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); }; const sysPrompt = _buildCodeSystemPrompt(); + // 이미지 첨부 시 content를 배열로 구성 (vision API 포맷) + const userContent = attachedImages.length + ? [ + { type: 'text', text: execContextMsg }, + ...attachedImages.map(img => ({ type: 'image_url', image_url: { url: img.dataUrl } })), + ] + : execContextMsg; let messages = [ { role: 'system', content: sysPrompt }, ..._histForAPI(codeAiHistory), - { role: 'user', content: execContextMsg }, + { role: 'user', content: userContent }, ]; let finalReply = '', _firstToolSeen = false; let liveTextEl = null, segReply = ''; @@ -2159,7 +2234,7 @@ async function _pollLogBubble() { const r = await fetch('/api/logs', { headers: authHeaders({}) }); if (!r.ok) return; const data = await r.json(); - const lines = (data.entries || []).slice(-4).map(e => e.msg.slice(0, 90)); + const lines = (data.entries || []).filter(e => !/^\[v\d\]|^\[task\]|^\[orch\]/.test(e.msg)).slice(-4).map(e => e.msg.slice(0, 90)); const linesEl = _logBubble.querySelector('.log-lines'); if (linesEl) linesEl.textContent = lines.join('\n') || '...'; codeAiScrollBottom(); @@ -2270,6 +2345,7 @@ async function _executeToolLocal(name, args) { const fname = args.file || ''; const newContent = args.content ?? ''; if (!fname) return { result: '오류: file 인수가 필요합니다', error: true }; + if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true }; const bare = _clientBareFileName(fname); const diskPath = _clientProjectPath(bare); if (localDirHandle) await localWriteFile(diskPath, newContent); @@ -2282,6 +2358,7 @@ async function _executeToolLocal(name, args) { const oldStr = args.old_string ?? ''; const newStr = args.new_string ?? ''; if (!fname) return { result: '오류: file 인수가 필요합니다', error: true }; + if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true }; const bare = _clientBareFileName(fname); const existing = await _codeAiReadLocalFile(bare); if (existing === null) return { result: `오류: ${bare}을(를) 찾을 수 없습니다`, error: true }; @@ -2333,6 +2410,7 @@ async function _executeToolLocal(name, args) { case 'Bash': { const cmd = args.command || ''; if (!cmd) return { result: '오류: command 인수가 필요합니다', error: true }; + if (!localDirHandle) return { result: '⚠️ Bash를 사용하려면 로컬 폴더를 먼저 연결해주세요 (🖥️ 버튼)', error: true }; if (!companionWs || companionWs.readyState !== WebSocket.OPEN) { return { result: '⚠️ companion.py가 연결되지 않았습니다. Bash 명령을 실행하려면 companion.py를 먼저 실행하세요.', error: true }; } @@ -3849,7 +3927,7 @@ async function codeAiClear() { if (projName) { await localDirHandle.getDirectoryHandle(projName, { create: true }).catch(() => {}); switchProject(projName); - codeAiAppend('system', `📁 프로젝트 "${projName}" 생성됨. 어떤 걸 만들지 설명해주세요.`); + codeAiAppend('system', `📁 프로젝트 "${projName}" 생성됨 (빈 폴더). 파일은 Write 툴로 만드세요. Bash는 companion 연결 시에만 사용 가능합니다. 어떤 걸 만들지 설명해주세요.`); return; } } @@ -4329,6 +4407,53 @@ function csbKarpathy(checked) { if (btn) btn.classList.toggle('active', _karpathyMode); } +// ── Chat image attach (paste / drag-drop) ──────────────────────────────────── +function _codeAiAddImage(file) { + if (!file || !file.type.startsWith('image/')) return; + const reader = new FileReader(); + reader.onload = e => { + _pendingImages.push({ dataUrl: e.target.result, mimeType: file.type }); + _codeAiRenderImagePreview(); + }; + reader.readAsDataURL(file); +} + +function _codeAiPasteImage(e) { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + e.preventDefault(); + _codeAiAddImage(item.getAsFile()); + } + } +} + +function _codeAiDropImage(e) { + e.preventDefault(); + document.getElementById('code-ai-input-row')?.classList.remove('drag-over'); + for (const file of (e.dataTransfer?.files || [])) _codeAiAddImage(file); +} + +function _codeAiRenderImagePreview() { + const wrap = document.getElementById('code-ai-img-preview'); + if (!wrap) return; + if (!_pendingImages.length) { wrap.style.display = 'none'; wrap.innerHTML = ''; return; } + wrap.style.display = 'flex'; + wrap.innerHTML = ''; + _pendingImages.forEach((img, i) => { + const thumb = document.createElement('div'); + thumb.className = 'code-ai-img-thumb'; + thumb.innerHTML = ``; + wrap.appendChild(thumb); + }); +} + +function _codeAiRemoveImage(i) { + _pendingImages.splice(i, 1); + _codeAiRenderImagePreview(); +} + let _hideBlankLines = false; function csbHideBlankLines(checked) { _hideBlankLines = !!checked; diff --git a/web-ui/esp32-flasher.html b/web-ui/esp32-flasher.html new file mode 100644 index 0000000..117a841 --- /dev/null +++ b/web-ui/esp32-flasher.html @@ -0,0 +1,471 @@ + + + + + +ESP32 Flasher + + + + +
+

⚡ ESP32 Flasher

+ sketch.ino + ESP32 + 대기 중 +
+ + + +
+
+ +
+
+ +
+
연결 설정
+
+ +
+
Chrome/Edge에서 USB 연결 필요
+
+ + +
+
펌웨어 정보
+
파일-
+
크기-
+
플래시 주소0x0000 (merged)
+
칩-
+
+ + +
+
플래시 진행률
+
+
+ 대기 중 + 0% +
+
+
+
+ + +
+
플래시 후 시리얼 모니터
+
+ + +
+
+
+ + +
+
+ CONSOLE + +
+
+ ESP32 Flasher 준비됨. 포트를 연결하고 ⚡ 플래시를 누르세요.\n +
+
+ + +
+
+
+ + + + +
+
+
+

컴파일 중...

+ + +
+
+ + + + diff --git a/web-ui/styles.css b/web-ui/styles.css index 879520c..dbfbe48 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -2844,6 +2844,8 @@ background: #0a1812; color: #c8e6c0; min-height: 38px; max-height: 200px; transition: border-color .2s, box-shadow .2s; } +.code-ai-input-row.drag-over { outline: 2px solid #22c55e; border-radius: 4px; } +.code-ai-img-thumb { position: relative; display: inline-flex; } #code-ai-input::placeholder { color: #3d6b4f; } #code-ai-input:focus { outline: none; border-color: #22c55e;