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:
+130
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user