Code editor: Arduino 에뮬레이터(avr8js) + ESP32 Web Serial 플래셔

- web-ui/arduino-emulator.html: avr8js 기반 ATmega328P 에뮬레이터
  (핀 상태 패널, 보드 LED, 시리얼 모니터, 속도 조절)
  - cpu.tick() 누락 수정: delay()/millis()/Serial 동작 안 하던 버그
  - 시리얼 라인 추적(serialLineSpan): D13 sys 메시지 끼어들 때 줄 깨짐 수정
- web-ui/esp32-flasher.html: Web Serial로 merged.bin 플래시
- POST /api/arduino/compile: arduino-cli로 uno/nano hex, esp32 merged.bin 컴파일
- POST /api/code/round: 클라이언트 주도 코드 에디터용 1라운드 SSE 엔드포인트
- code.js: 실행 모드에 Arduino 에뮬레이터 / ESP32 플래시 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-11 16:24:45 +09:00
co-authored by Claude Fable 5
parent f00c453de9
commit fae47a0962
6 changed files with 1204 additions and 9 deletions
+130 -5
View File
@@ -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 = `<img src="${img.dataUrl}" style="max-width:80px;max-height:60px;border-radius:4px;display:block"><button onclick="_codeAiRemoveImage(${i})" title="제거" style="position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;line-height:1;padding:0">×</button>`;
wrap.appendChild(thumb);
});
}
function _codeAiRemoveImage(i) {
_pendingImages.splice(i, 1);
_codeAiRenderImagePreview();
}
let _hideBlankLines = false;
function csbHideBlankLines(checked) {
_hideBlankLines = !!checked;