v3.0.1: 에뮬레이터 업그레이드 — 정지/재시작, UTF-8 시리얼, CodeMirror, 속도슬라이더
코드 에디터 (code.html / code.js)
- xterm → @xterm/xterm 5.5.0 마이그레이션 (UMD 전역명 AddonFit/AddonWebLinks)
- Monaco Editor 0.52.0 업그레이드
- AI 패널 CSS 변수 (--ai-hdr-bg 등) 다크/라이트 테마 분리
- Arduino/GCC/Python/JS 오류 패턴(_ERROR_PATTERNS) 확장
Arduino 에뮬레이터 (arduino-emulator.html)
- CodeMirror 5 스케치 에디터 내장 (C++ 구문 강조, BroadcastChannel 동기화)
- 속도 select → range 슬라이더 (1x~최대, 로그 스케일)
- 시리얼 UTF-8: 수동 바이트버퍼 → 스트리밍 TextDecoder({ stream:true })로 교체 (°C 깨짐 수정)
- 정지/시작 토글 버튼 추가 (AVR: togglePower, ESP32/ESP8266: pause↔restart)
- 회로→코드 버튼 (🤖) 추가
- 보드 타이틀 FQBN 기반 동적 설정
서버 (server-v2.ts)
- QEMU/ESP8266 restart 엔드포인트: old PTY exit 핸들러가 새 세션을 삭제하던 레이스컨디션 수정 (s.pty = null 가드)
- ESP8266 /pause 엔드포인트 추가
- 정지 시 DELETE → pause로 변경 (세션·firmware 유지, 재시작 가능)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+323
-66
@@ -133,7 +133,7 @@ function initCodeView() {
|
||||
setCodeAiTech(codeAiTech);
|
||||
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.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs' } });
|
||||
require(['vs/editor/editor.main'], function() {
|
||||
monacoReady = true;
|
||||
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||||
@@ -154,6 +154,17 @@ function initCodeView() {
|
||||
tabSize: 2,
|
||||
});
|
||||
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
|
||||
// Broadcast .ino edits to emulator in real-time
|
||||
let _syncDebounce = null;
|
||||
monacoEditor.onDidChangeModelContent(() => {
|
||||
if (_esp32SyncBusy) return;
|
||||
clearTimeout(_syncDebounce);
|
||||
_syncDebounce = setTimeout(() => {
|
||||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||||
if (!file || !file.name.endsWith('.ino')) return;
|
||||
_esp32CodeChannel.postMessage({ type: 'code', code: monacoEditor.getValue() });
|
||||
}, 300);
|
||||
});
|
||||
codeFlushPendingQueue();
|
||||
if (!codeFiles.length && !codePendingQueue.length) {
|
||||
// Don't create a placeholder file — the model will create files as needed
|
||||
@@ -490,12 +501,26 @@ function _termBufferAppend(shellId, raw) {
|
||||
}
|
||||
|
||||
const _ERROR_PATTERNS = [
|
||||
// Python
|
||||
/Traceback \(most recent call last\)/,
|
||||
/(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/,
|
||||
/^\s*\^\s*$/, // Python caret indicator
|
||||
/^\s*\^\s*$/,
|
||||
// JavaScript / Node
|
||||
/Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/,
|
||||
/Error: .+\n?\s+at /,
|
||||
/\[Error\]/i,
|
||||
// GCC / Arduino 컴파일 에러
|
||||
/\berror:\s/,
|
||||
/undefined reference to\b/,
|
||||
/multiple definition of\b/,
|
||||
/expected\s.+before\s/,
|
||||
/invalid conversion from\b/,
|
||||
/cannot convert\b/,
|
||||
/sketch too big/i,
|
||||
/\w+\.ino:\d+:\d+:.*error/,
|
||||
/\w+\.cpp:\d+:\d+:.*error/,
|
||||
/recipe for target.*failed/i,
|
||||
/make\[1\].*Error/,
|
||||
];
|
||||
let _lastErrorShellId = null;
|
||||
let _lastErrorText = '';
|
||||
@@ -543,7 +568,7 @@ function _sendTerminalErrorToAI(errorText) {
|
||||
const trimmed = filtered.slice(blockStart).slice(-30).join('\n');
|
||||
if (!trimmed) return;
|
||||
|
||||
input.value = '다음 에러를 고쳐줘. coder_patch_file 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
|
||||
input.value = '다음 에러를 고쳐줘. Read로 파일을 읽은 뒤 Edit 또는 Write 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
|
||||
input.style.height = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
codeAiSend();
|
||||
@@ -737,8 +762,8 @@ function createXtermShell(container, shellId) {
|
||||
fontFamily: "'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', 'Malgun Gothic', 'AppleGothic', monospace",
|
||||
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
const fitAddon = new AddonFit.FitAddon();
|
||||
const webLinksAddon = new AddonWebLinks.WebLinksAddon();
|
||||
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
|
||||
term.open(sub); fitAddon.fit();
|
||||
// Intercept Ctrl+` inside xterm to toggle the terminal panel.
|
||||
@@ -887,32 +912,107 @@ function toggleCodePreview() {
|
||||
function openArduinoEmulator() {
|
||||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||||
const btn = document.getElementById('code-emu-btn');
|
||||
if (!file || !file.name.endsWith('.ino')) { window.open('/arduino-emulator.html', 'arduino-emulator'); return; }
|
||||
|
||||
if (file && file.name.endsWith('.ino')) {
|
||||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||||
const fqbn = _csbSelBoard.fqbn;
|
||||
const fqbnArch = fqbn.split(':')[1] || '';
|
||||
|
||||
if (fqbnArch === 'esp32') {
|
||||
// ESP32 계열 → QEMU (DIO 모드로 서버가 직접 컴파일)
|
||||
// 팝업 차단 방지: 클릭 직후 즉시 창 열고, 완료 후 URL 교체
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
codeAiAppend('system', `🖥️ ESP32 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
|
||||
// Store sketch code so emulator can show it (localStorage: shared across windows)
|
||||
try { localStorage.setItem('esp32_sketch', content); } catch {}
|
||||
api('/api/arduino/qemu/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
|
||||
.then(d => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (d.error) {
|
||||
emuWin.close();
|
||||
const isUnsupported = d.error.includes('미지원') || d.error.includes('RISC-V');
|
||||
if (isUnsupported) codeAiAppend('system', '⚠️ ' + d.error);
|
||||
else sendCompileErrorToAI(d.error, file.name, content);
|
||||
return;
|
||||
}
|
||||
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
|
||||
codeAiAppend('system', `✅ ESP32 에뮬레이터 실행 중.`);
|
||||
}).catch(e => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
codeAiAppend('system', '⚠️ ' + e.message);
|
||||
emuWin.close();
|
||||
});
|
||||
} else if (fqbnArch === 'esp8266') {
|
||||
// ESP8266 → Native Linux x86 컴파일 + 실행
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
codeAiAppend('system', `🖥️ ESP8266 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
|
||||
try { localStorage.setItem('esp8266_sketch', content); } catch {}
|
||||
api('/api/arduino/esp8266/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
|
||||
.then(d => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (d.error) {
|
||||
emuWin.close();
|
||||
sendCompileErrorToAI(d.error, file.name, content);
|
||||
return;
|
||||
}
|
||||
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
|
||||
codeAiAppend('system', `✅ ESP8266 에뮬레이터 실행 중.`);
|
||||
}).catch(e => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
codeAiAppend('system', '⚠️ ' + e.message);
|
||||
emuWin.close();
|
||||
});
|
||||
} else {
|
||||
// AVR 및 기타 → 기존 컴파일 → AVR 에뮬레이터 or 다운로드
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
fetch('/api/arduino/compile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders({}) },
|
||||
body: JSON.stringify({ code: file.content, board: 'uno' }),
|
||||
body: JSON.stringify({ code: content, board: fqbn, fqbn }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (data.autoInstalled?.length) codeAiAppend('system', `📦 라이브러리 자동 설치: ${data.autoInstalled.join(', ')}`);
|
||||
if (data.error) { sendCompileErrorToAI(data.error, file.name, file.content); return; }
|
||||
sessionStorage.setItem('arduino_sketch', file.content);
|
||||
sessionStorage.setItem('arduino_hex', data.hex);
|
||||
sessionStorage.setItem('arduino_filename', file.name);
|
||||
sessionStorage.setItem('arduino_board', 'uno');
|
||||
const w = window.open('/arduino-emulator.html', 'arduino-emulator');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||||
if (data.error) { sendCompileErrorToAI(data.error, file.name, content); return; }
|
||||
if (data.hex) {
|
||||
sessionStorage.setItem('arduino_hex', data.hex);
|
||||
const w = window.open('/arduino-emulator.html', 'arduino-emulator');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||||
} else if (data.bin) {
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
|
||||
const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = file.name.replace(/\.ino$/, '') + ext; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
codeAiAppend('system', `✅ 컴파일 완료 (${(data.size/1024).toFixed(1)} KB) — ${ext.slice(1).toUpperCase()} 다운로드됨.\n보드에 올리려면 ⚡ 업로드 버튼을 사용하세요.`);
|
||||
}
|
||||
}).catch(e => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
codeAiAppend('system', '⚠️ 서버 오류: ' + e.message);
|
||||
});
|
||||
} else {
|
||||
window.open('/arduino-emulator.html', 'arduino-emulator');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Arduino 업로드 페이지 열기 ────────────────────────────────────────────────
|
||||
function csbUploadToBoard() {
|
||||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||||
if (!file || !file.name.endsWith('.ino')) {
|
||||
codeAiAppend('system', '⚠️ .ino 파일을 선택해주세요'); return;
|
||||
}
|
||||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||||
sessionStorage.setItem('arduino_upload_code', content);
|
||||
sessionStorage.setItem('arduino_upload_fqbn', _csbSelBoard.fqbn);
|
||||
sessionStorage.setItem('arduino_upload_board', _csbSelBoard.name);
|
||||
sessionStorage.setItem('arduino_upload_file', file.name);
|
||||
const w = window.open('/arduino-upload.html', '_blank');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨');
|
||||
}
|
||||
|
||||
function codePreviewRefresh() {
|
||||
const urlInput = document.getElementById('code-preview-url');
|
||||
const iframe = document.getElementById('code-preview-iframe');
|
||||
@@ -1315,33 +1415,48 @@ function codeRunWith(mode) {
|
||||
}
|
||||
|
||||
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.autoInstalled?.length) codeAiAppend('system', `📦 라이브러리 자동 설치: ${data.autoInstalled.join(', ')}`);
|
||||
if (data.error) {
|
||||
sendCompileErrorToAI(data.error, file.name, file.content);
|
||||
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));
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
codeAiAppend('system', `🖥️ ESP32 에뮬레이터 컴파일 중... (${file.name})`);
|
||||
try { localStorage.setItem('esp32_sketch', file.content); } catch {}
|
||||
api('/api/arduino/qemu/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: 'esp32:esp32:esp32' }) })
|
||||
.then(d => {
|
||||
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); emuWin.close(); return; }
|
||||
const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=esp32%3Aesp32%3Aesp32${_proj2}`;
|
||||
codeAiAppend('system', `✅ ESP32 에뮬레이터 실행 중.`);
|
||||
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); emuWin.close(); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'esp8266') {
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
codeAiAppend('system', `🖥️ ESP8266 에뮬레이터 컴파일 중... (${file.name})`);
|
||||
try { localStorage.setItem('esp8266_sketch', file.content); } catch {}
|
||||
const _esp8266Fqbn = (_csbSelBoard.fqbn.startsWith('esp8266:')) ? _csbSelBoard.fqbn : 'esp8266:esp8266:nodemcuv2';
|
||||
api('/api/arduino/esp8266/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: _esp8266Fqbn }) })
|
||||
.then(d => {
|
||||
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); emuWin.close(); return; }
|
||||
const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(_esp8266Fqbn)}${_proj2}`;
|
||||
codeAiAppend('system', `✅ ESP8266 에뮬레이터 실행 중.`);
|
||||
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); emuWin.close(); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'arduino') {
|
||||
const board = 'uno';
|
||||
codeAiAppend('system', `🔌 컴파일 중... (${file.name})`);
|
||||
const _isEsp32Board = (_csbSelBoard.fqbn.split(':')[1] || '') === 'esp32';
|
||||
const _isEsp8266Board = (_csbSelBoard.fqbn.split(':')[0] || '') === 'esp8266';
|
||||
if (_isEsp32Board || _isEsp8266Board) {
|
||||
// ESP32/ESP8266 계열 — 팝업 차단 방지를 위해 openArduinoEmulator()와 같은 경로 사용
|
||||
openArduinoEmulator(); return;
|
||||
}
|
||||
codeAiAppend('system', `🔌 컴파일 중... (${file.name}) [${_csbSelBoard.name}]`);
|
||||
fetch('/api/arduino/compile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders({}) },
|
||||
body: JSON.stringify({ code: file.content, board }),
|
||||
body: JSON.stringify({ code: file.content, board: _csbSelBoard.fqbn, fqbn: _csbSelBoard.fqbn }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.autoInstalled?.length) codeAiAppend('system', `📦 라이브러리 자동 설치: ${data.autoInstalled.join(', ')}`);
|
||||
if (data.error) {
|
||||
@@ -1349,11 +1464,28 @@ function codeRunWith(mode) {
|
||||
sendCompileErrorToAI(data.error + extra, file.name, file.content);
|
||||
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', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||||
sessionStorage.setItem('arduino_project', activeProjectName || '');
|
||||
sessionStorage.setItem('arduino_board', _csbSelBoard.fqbn);
|
||||
// boardType에 따라 결과 처리
|
||||
if (data.merged) {
|
||||
// (이미 위에서 처리됨, fallback)
|
||||
codeAiAppend('system', `✅ 컴파일 완료.`);
|
||||
} else if (data.hex) {
|
||||
// AVR/ESP8266 → 에뮬레이터
|
||||
sessionStorage.setItem('arduino_hex', data.hex);
|
||||
const w = window.open('/arduino-emulator.html', '_blank');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||||
} else if (data.bin) {
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
|
||||
const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = file.name.replace(/\.ino$/, '') + ext; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
const kb = (data.size / 1024).toFixed(1);
|
||||
codeAiAppend('system', `✅ 컴파일 완료 (${kb} KB) — ${ext.slice(1).toUpperCase()} 다운로드됨.\n보드에 올리려면 ⚡ 업로드 버튼을 사용하세요. [${_csbSelBoard.name}]`);
|
||||
}
|
||||
}).catch(e => codeAiAppend('system', '⚠️ 서버 오류: ' + e.message));
|
||||
return;
|
||||
}
|
||||
@@ -1385,8 +1517,7 @@ function codeRunWith(mode) {
|
||||
function sendCompileErrorToAI(error, filename, code) {
|
||||
const inp = document.getElementById('code-ai-input');
|
||||
if (!inp) return;
|
||||
const codeBlock = code ? `\n\n코드:\n\`\`\`cpp\n${code}\n\`\`\`` : '';
|
||||
inp.value = `아두이노 컴파일 오류가 발생했습니다. 원인을 설명하고 수정해줘.\n\n파일: ${filename}\n오류:\n\`\`\`\n${error}\n\`\`\`${codeBlock}`;
|
||||
inp.value = `아두이노 컴파일 오류가 발생했습니다. Read로 ${filename}을 읽은 뒤 Edit 또는 Write 도구로 파일을 직접 수정해줘.\n\n오류:\n\`\`\`\n${error}\n\`\`\``;
|
||||
codeAiSend();
|
||||
}
|
||||
|
||||
@@ -2252,6 +2383,7 @@ async function _codeAiSendImpl() {
|
||||
// ── Live server log bubble ────────────────────────────────────────────────
|
||||
let _logBubble = null;
|
||||
let _logPollTimer = null;
|
||||
let _logBubbleStartCount = 0; // totalCount watermark — only show logs from after bubble appeared
|
||||
|
||||
function _showLogBubble() {
|
||||
if (_logBubble) return;
|
||||
@@ -2259,11 +2391,14 @@ function _showLogBubble() {
|
||||
if (!msgs) return;
|
||||
_logBubble = document.createElement('div');
|
||||
_logBubble.className = 'code-ai-log-bubble';
|
||||
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines">서버 연결 중...</span>';
|
||||
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines"></span>';
|
||||
msgs.appendChild(_logBubble);
|
||||
codeAiScrollBottom();
|
||||
_pollLogBubble();
|
||||
_logPollTimer = setInterval(_pollLogBubble, 800);
|
||||
// Record current totalCount so we only show logs that arrive during AI processing
|
||||
fetch('/api/logs', { headers: authHeaders({}) })
|
||||
.then(r => r.json()).then(d => { _logBubbleStartCount = d.totalCount || 0; })
|
||||
.catch(() => {})
|
||||
.finally(() => { _logPollTimer = setInterval(_pollLogBubble, 800); });
|
||||
}
|
||||
|
||||
async function _pollLogBubble() {
|
||||
@@ -2272,9 +2407,12 @@ async function _pollLogBubble() {
|
||||
const r = await fetch('/api/logs', { headers: authHeaders({}) });
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const lines = (data.entries || []).filter(e => !/^\[v\d\]|^\[task\]|^\[orch\]/.test(e.msg)).slice(-4).map(e => e.msg.slice(0, 90));
|
||||
const allEntries = data.entries || [];
|
||||
const newCount = Math.max(0, (data.totalCount || 0) - _logBubbleStartCount);
|
||||
const newEntries = newCount > 0 ? allEntries.slice(-Math.min(newCount, allEntries.length)) : [];
|
||||
const lines = newEntries.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') || '...';
|
||||
if (linesEl) linesEl.textContent = lines.join('\n');
|
||||
codeAiScrollBottom();
|
||||
} catch {}
|
||||
}
|
||||
@@ -2594,9 +2732,14 @@ function _buildCodeSystemPrompt() {
|
||||
- 프로젝트 루트 기준 상대 경로만 사용하세요. 예: \`main.ino\`, \`src/utils.cpp\`, \`lib/helper.h\`
|
||||
- 새 프로젝트를 만들면 해당 폴더가 루트입니다. 별도의 상위 폴더를 만들지 마세요.
|
||||
|
||||
## 회로도 파일 규칙 (중요)
|
||||
- \`circuit.json\` (회로도)은 사용자가 **명시적으로 회로도 편집을 요청**할 때만 읽거나 수정하세요.
|
||||
- "TFT 연결해줘", "회로도에 부품 추가해줘" 같이 회로도 편집을 직접 언급한 경우에만 해당.
|
||||
- 코드 작성·분석·업로드 등 일반 작업 중에는 circuit.json을 열지 마세요.
|
||||
|
||||
## 응답 원칙
|
||||
- 행동 중심으로 응답하세요: 설명만 하지 말고 실제로 파일을 수정하세요.
|
||||
- 변경 사항은 간결하게 설명하고, 코드 블록으로 전체 내용을 반복하지 마세요.
|
||||
- **코드 변경은 반드시 Edit 또는 Write 도구로만** 하세요. 텍스트로 "수정했습니다", "수정 완료", 마크다운 코드 블록 출력으로 파일이 바뀌지 않습니다.
|
||||
- 변경 사항은 도구 호출 후 한 줄로 간결하게 설명하세요.
|
||||
- 여러 파일을 수정해야 한다면 순서대로 각각 처리하세요.`;
|
||||
}
|
||||
|
||||
@@ -4150,6 +4293,116 @@ async function csbLibUninstall(name) {
|
||||
}
|
||||
|
||||
// ── 보드 매니저 ───────────────────────────────────────────────────────────────
|
||||
// ── 보드 선택기 ───────────────────────────────────────────────────────────────
|
||||
const CSB_BOARD_DEFAULT = { fqbn: 'arduino:avr:uno', name: 'Arduino UNO' };
|
||||
let _csbSelBoard = (() => {
|
||||
try { return JSON.parse(localStorage.getItem('csb_selected_board') || 'null') || CSB_BOARD_DEFAULT; }
|
||||
catch { return CSB_BOARD_DEFAULT; }
|
||||
})();
|
||||
let _csbSelAllBoards = [];
|
||||
|
||||
function _csbBoardSelSave(board) {
|
||||
_csbSelBoard = board;
|
||||
try { localStorage.setItem('csb_selected_board', JSON.stringify(board)); } catch {}
|
||||
const el = document.getElementById('csb-board-sel-name');
|
||||
if (el) el.textContent = board.name;
|
||||
const badge = document.getElementById('csb-board-qemu-badge');
|
||||
if (badge) {
|
||||
if (board.qemu === 'esp8266-native') {
|
||||
badge.textContent = '🖥 Native'; badge.style.display = 'inline';
|
||||
badge.style.background = '#0c3a4a'; badge.style.color = '#67e8f9'; badge.style.borderColor = '#0e7490';
|
||||
} else if (board.qemu) {
|
||||
badge.textContent = '🖥 QEMU'; badge.style.display = 'inline';
|
||||
badge.style.background = board.qemu === 'esp32s3' ? '#1a3a5a' : '#1a3a1a';
|
||||
badge.style.color = board.qemu === 'esp32s3' ? '#60a5fa' : '#22c55e';
|
||||
badge.style.borderColor = board.qemu === 'esp32s3' ? '#2a5a8a' : '#2a5a2a';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function csbBoardSelOpen() {
|
||||
const dd = document.getElementById('csb-board-sel-dropdown');
|
||||
if (!dd) return;
|
||||
const open = dd.style.display !== 'none';
|
||||
dd.style.display = open ? 'none' : 'flex';
|
||||
if (!open) {
|
||||
const q = document.getElementById('csb-board-sel-q');
|
||||
if (q) { q.value = ''; q.focus(); }
|
||||
csbBoardSelSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function csbBoardSelClose() {
|
||||
const dd = document.getElementById('csb-board-sel-dropdown');
|
||||
if (dd) dd.style.display = 'none';
|
||||
}
|
||||
|
||||
async function csbBoardSelSearch() {
|
||||
const q = (document.getElementById('csb-board-sel-q')?.value || '').trim();
|
||||
const list = document.getElementById('csb-board-sel-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div style="padding:10px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
|
||||
try {
|
||||
const data = await api('/api/arduino/boards/listall?q=' + encodeURIComponent(q));
|
||||
const boards = data.boards || [];
|
||||
if (!boards.length) { list.innerHTML = '<div style="padding:10px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
|
||||
list.innerHTML = boards.map(b => {
|
||||
const sel = b.fqbn === _csbSelBoard.fqbn;
|
||||
const qemuBadge = b.qemu === 'esp8266-native'
|
||||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#0c3a4a;color:#67e8f9;border:1px solid #0e7490">🖥 Native</span>`
|
||||
: b.qemu
|
||||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;
|
||||
background:${b.qemu==='esp32s3'?'#1a3a5a':'#1a3a1a'};
|
||||
color:${b.qemu==='esp32s3'?'#60a5fa':'#22c55e'};
|
||||
border:1px solid ${b.qemu==='esp32s3'?'#2a5a8a':'#2a5a2a'}">🖥 QEMU</span>`
|
||||
: '';
|
||||
const varBadge = (b.variants > 1)
|
||||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:var(--panel-2);color:var(--muted);border:1px solid var(--line)">+${b.variants-1}</span>`
|
||||
: '';
|
||||
return `<div data-fqbn="${_csbEsc(b.fqbn)}" data-name="${_csbEsc(b.name)}"
|
||||
data-qemu="${b.qemu||''}" data-chip="${b.chip||''}"
|
||||
style="padding:7px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:2px;${sel?'background:var(--brand);color:#fff':''}">
|
||||
<span style="display:flex;align-items:center;flex-wrap:wrap;gap:0">
|
||||
<span style="font-weight:600;${sel?'color:#fff':'color:var(--text)'}">${_csbEsc(b.name)}</span>
|
||||
${sel?'':qemuBadge}${sel?'':varBadge}
|
||||
</span>
|
||||
<span style="font-size:10px;${sel?'color:rgba(255,255,255,.75)':'color:var(--muted)'}">${_csbEsc(b.fqbn)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-fqbn]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
_csbBoardSelSave({ fqbn: el.dataset.fqbn, name: el.dataset.name,
|
||||
qemu: el.dataset.qemu || false, chip: el.dataset.chip || '' });
|
||||
csbBoardSelClose();
|
||||
});
|
||||
el.addEventListener('mouseenter', () => { if (el.dataset.fqbn !== _csbSelBoard.fqbn) el.style.background = 'var(--bg)'; });
|
||||
el.addEventListener('mouseleave', () => { if (el.dataset.fqbn !== _csbSelBoard.fqbn) el.style.background = ''; });
|
||||
});
|
||||
} catch(e) { list.innerHTML = `<div style="padding:10px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
function csbBoardSelKey(e) {
|
||||
if (e.key === 'Escape') csbBoardSelClose();
|
||||
}
|
||||
|
||||
// 드롭다운 외부 클릭 시 닫기
|
||||
document.addEventListener('click', e => {
|
||||
const display = document.getElementById('csb-board-sel-display');
|
||||
const dd = document.getElementById('csb-board-sel-dropdown');
|
||||
if (dd && display && !display.contains(e.target) && !dd.contains(e.target))
|
||||
dd.style.display = 'none';
|
||||
});
|
||||
|
||||
// 초기 표시 업데이트
|
||||
(function() {
|
||||
const el = document.getElementById('csb-board-sel-name');
|
||||
if (el) el.textContent = _csbSelBoard.name;
|
||||
// 저장된 보드에 qemu 정보가 있으면 배지 표시
|
||||
if (_csbSelBoard.qemu) _csbBoardSelSave(_csbSelBoard);
|
||||
})();
|
||||
|
||||
let _csbBoardCurrentTab = 'installed';
|
||||
|
||||
function csbBoardTab(tab) {
|
||||
@@ -4162,7 +4415,7 @@ function csbBoardTab(tab) {
|
||||
if (sr) sr.style.display = tab === 'search' ? 'flex' : 'none';
|
||||
document.getElementById('csb-board-status').textContent = '';
|
||||
if (tab === 'installed') csbBoardLoad();
|
||||
else { document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">키워드를 입력하세요.</div>'; }
|
||||
else csbBoardSearch();
|
||||
}
|
||||
|
||||
function _csbBoardStatus(msg) { const el = document.getElementById('csb-board-status'); if (el) el.textContent = msg; }
|
||||
@@ -4170,10 +4423,9 @@ function _csbBoardStatus(msg) { const el = document.getElementById('csb-board-st
|
||||
async function csbBoardLoad() {
|
||||
document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/arduino/cores', { headers: authHeaders() });
|
||||
const data = await r.json();
|
||||
const data = await api('/api/arduino/cores');
|
||||
_csbBoardRenderInstalled(data.cores || []);
|
||||
} catch { document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
|
||||
} catch(e) { document.getElementById('csb-board-list').innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
function _csbBoardRenderInstalled(cores) {
|
||||
@@ -4201,10 +4453,9 @@ async function csbBoardSearch() {
|
||||
document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 중...</div>';
|
||||
_csbBoardStatus('');
|
||||
try {
|
||||
const r = await fetch('/api/arduino/cores/search?q=' + encodeURIComponent(q), { headers: authHeaders() });
|
||||
const data = await r.json();
|
||||
const data = await api('/api/arduino/cores/search?q=' + encodeURIComponent(q));
|
||||
_csbBoardRenderSearch(data.cores || []);
|
||||
} catch { document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
|
||||
} catch(e) { document.getElementById('csb-board-list').innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
function _csbBoardRenderSearch(cores) {
|
||||
@@ -4232,11 +4483,7 @@ async function csbBoardInstall(id, btn) {
|
||||
_csbBoardStatus(`설치 중: ${id} (수 분 소요)...`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||||
try {
|
||||
const r = await fetch('/api/arduino/cores/install', {
|
||||
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const data = await r.json();
|
||||
const data = await api('/api/arduino/cores/install', { method: 'POST', body: JSON.stringify({ id }) });
|
||||
if (data.error) { _csbBoardStatus('오류: ' + data.error.slice(0,150)); if(btn){btn.disabled=false;btn.textContent='설치';} return; }
|
||||
_csbBoardStatus(`✅ ${id} 설치 완료`);
|
||||
if (btn) btn.outerHTML = `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`;
|
||||
@@ -4247,11 +4494,7 @@ async function csbBoardUninstall(id) {
|
||||
if (!confirm(`"${id}" 보드를 삭제할까요?`)) return;
|
||||
_csbBoardStatus(`삭제 중: ${id}...`);
|
||||
try {
|
||||
const r = await fetch('/api/arduino/cores/uninstall', {
|
||||
method: 'DELETE', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const data = await r.json();
|
||||
const data = await api('/api/arduino/cores/uninstall', { method: 'DELETE', body: JSON.stringify({ id }) });
|
||||
if (data.error) { _csbBoardStatus('오류: ' + data.error.slice(0,100)); return; }
|
||||
_csbBoardStatus(`✅ ${id} 삭제 완료`);
|
||||
csbBoardLoad();
|
||||
@@ -5172,6 +5415,20 @@ function setupCodeVResize() {
|
||||
|
||||
// ── Standalone bootstrap ────────────────────────────────────────────────────
|
||||
// In the main app these are triggered by setMode('code') / setSidebarTab('code').
|
||||
// BroadcastChannel: real-time code sync between code editor and emulator
|
||||
const _esp32CodeChannel = new BroadcastChannel('esp32_code_sync');
|
||||
let _esp32SyncBusy = false;
|
||||
_esp32CodeChannel.onmessage = (e) => {
|
||||
if (e.data?.type !== 'code') return;
|
||||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||||
if (!file || !file.name.endsWith('.ino') || !monacoEditor) return;
|
||||
_esp32SyncBusy = true;
|
||||
const pos = monacoEditor.getPosition();
|
||||
if (file.model) file.model.setValue(e.data.code);
|
||||
if (pos) monacoEditor.setPosition(pos);
|
||||
_esp32SyncBusy = false;
|
||||
};
|
||||
|
||||
// Here we run them once the DOM is ready since this page IS the code view.
|
||||
(function bootCodePage() {
|
||||
async function boot() {
|
||||
|
||||
Reference in New Issue
Block a user