Release 2.8.1: pptx-wizard slide editor + fixes

- Fix pptx-wizard slide editor modal opening off-screen (header clipped)
  when a saved drag position is restored on a fresh page load: clear the
  CSS centering transform and clamp coordinates into the viewport.
- Add pptx-wizard.html and python-runner.html web UIs.
- Bump version to 2.8.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-30 20:14:01 +09:00
co-authored by Claude Opus 4.8
parent 22bfc3ac27
commit 957a13f98c
17 changed files with 4867 additions and 103 deletions
+217 -2
View File
@@ -560,9 +560,11 @@ function setSidebarTab(tab) {
document.getElementById('tab-jobs').classList.toggle('active', tab === 'jobs');
document.getElementById('tab-skills').classList.toggle('active', tab === 'skills');
document.getElementById('tab-code').classList.toggle('active', tab === 'code');
document.getElementById('tab-pptx').classList.toggle('active', tab === 'pptx');
document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none';
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
document.getElementById('sidebar-code').style.display = tab === 'code' ? 'flex' : 'none';
document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none';
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
if (tab === 'code') { codeSbRefresh(); csbLoadModels(); csbLoadCodeDir(); }
}
@@ -1420,6 +1422,8 @@ async function sendChat(queuedMessage = null) {
`${stepNum ? `Step ${stepNum}: ` : ''}${action || 'tool'}${syntheticTag} => ${text || '(no output)'}`,
);
loadAgentSessionContext();
// PPTX: capture extracted images
if (ok && action === 'pdf_extract_images') pptxCaptureImages(text);
// Auto-open file in code editor when agent writes a code file
// (via create_file OR shell/run_command redirect captured at tool_call).
if (ok && (action === 'create_file' || action === 'shell' || action === 'run_command')) {
@@ -1724,6 +1728,14 @@ async function sendChat(queuedMessage = null) {
}
persistActiveChat();
// PPTX: detect pptx-outline block in final reply
if (finalReply) {
const outlineMatch = finalReply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
if (outlineMatch) {
try { pptxSetOutline(JSON.parse(outlineMatch[1])); } catch {}
}
}
// Auto-open code blocks from final reply in the editor
if (finalReply && typeof codeAiRenderMarkdown === 'function') {
const codeBlockRe = /```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```/g;
@@ -3263,7 +3275,7 @@ async function loadContextViewerTab() {
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return String(s || '').replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// --- PPT Template & Skin Tab ---------------------------------------------------
@@ -5013,7 +5025,7 @@ function log(text, type = 'log') {
// ---- Helpers ----
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return String(str).replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _normalizeFileUrl(url) {
@@ -9901,3 +9913,206 @@ function setupCodeVResize() {
}
}
// ── PPTX Workflow Sidebar ─────────────────────────────────────────────────────
let _pptxImages = []; // {url, thumb, label}
let _pptxOutline = null; // parsed pptx-outline JSON
let _pptxOutlineEdits = {}; // {index: {title, bullets}}
function setPptxSbTab(tab) {
['setup','images','outline'].forEach(t => {
document.getElementById('psb-tab-' + t).classList.toggle('active', t === tab);
const el = document.getElementById('psb-' + t);
if (el) el.style.display = t === tab ? (t === 'outline' ? 'flex' : (t === 'images' ? 'block' : 'flex')) : 'none';
});
}
function pptxSetStatus(msg) {
const el = document.getElementById('psb-status');
if (el) el.textContent = msg;
}
function pptxStartWorkflow() {
const topic = (document.getElementById('psb-topic').value || '').trim();
const pmcidsRaw = (document.getElementById('psb-pmcids').value || '').trim();
const template = document.getElementById('psb-template').value || 'dark';
const slideCount = parseInt(document.getElementById('psb-slide-count').value) || 0;
if (!topic) { pptxSetStatus('주제를 입력하세요.'); return; }
const pmcids = pmcidsRaw
.split(/[\n,]+/)
.map(s => s.trim().replace(/^PMC/i, 'PMC'))
.filter(Boolean);
_pptxImages = [];
_pptxOutline = null;
_pptxOutlineEdits = {};
pptxRenderImageGrid();
const cardsEl = document.getElementById('psb-outline-cards');
if (cardsEl) cardsEl.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 중...</div>';
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
// Build workflow message
const pmcLine = pmcids.length
? `\nPMCIDs: ${pmcids.join(', ')}`
: '';
const slideLine = slideCount ? `\n슬라이드 수: ${slideCount}장` : '';
const msg = `[PPTX 워크플로 시작]\n주제: ${topic}${pmcLine}\n템플릿: ${template}${slideLine}\n\n아래 순서로 진행해주세요:\n1. 작업 폴더 생성 (주제 기반)\n2. PDF 다운로드 (pubmed_fulltext, 위 PMCIDs)\n3. 이미지 추출 (pdf_extract_images, 각 PDF)\n4. 소형 이미지 필터링 (python_eval)\n5. 슬라이드 구성을 \`\`\`pptx-outline 블록으로 출력 (JSON 형식)\n6. 확인 대기 (create_presentation은 아직 호출하지 마세요)`;
pptxSetStatus('워크플로 시작...');
setSidebarTab('pptx');
setPptxSbTab('setup');
// Send via main chat
const input = document.getElementById('chat-input');
if (input) {
input.value = msg;
sendChat();
}
}
function pptxCaptureImages(toolResultText) {
// Parse image links from pdf_extract_images output: [label](/api/files/...)
const re = /\[([^\]]*)\]\((\/api\/files\/[^)]+\.(?:png|jpg|jpeg|gif|webp))\)/gi;
let m;
while ((m = re.exec(toolResultText)) !== null) {
const label = m[1];
const url = m[2];
if (!_pptxImages.some(i => i.url === url)) {
_pptxImages.push({ url, label });
}
}
if (_pptxImages.length) {
pptxRenderImageGrid();
const countEl = document.getElementById('psb-img-count');
if (countEl) countEl.textContent = `(${_pptxImages.length})`;
// Auto-switch to images tab if PPTX tab is active
if (sidebarTab === 'pptx') setPptxSbTab('images');
pptxSetStatus(`이미지 ${_pptxImages.length}개 추출됨`);
}
}
function pptxRenderImageGrid() {
const grid = document.getElementById('psb-image-grid');
if (!grid) return;
if (!_pptxImages.length) {
grid.innerHTML = '<div style="grid-column:1/-1;color:var(--muted);font-size:11px;padding:8px;text-align:center">이미지 추출 후 표시됩니다</div>';
return;
}
grid.innerHTML = _pptxImages.map((img, idx) => `
<div style="position:relative;border:1px solid var(--line);border-radius:6px;overflow:hidden;cursor:pointer;aspect-ratio:4/3;background:var(--panel-2)"
onclick="pptxImageClick(${idx})" title="${escHtml(img.label)}">
<img src="${escHtml(img.url)}" style="width:100%;height:100%;object-fit:cover" loading="lazy"
onerror="this.parentNode.style.background='var(--panel)';this.style.display='none'">
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.5);color:#fff;font-size:9px;padding:2px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(img.label)}</div>
</div>`).join('');
}
function pptxImageClick(idx) {
const img = _pptxImages[idx];
if (!img) return;
window.open(img.url, '_blank', 'noopener');
}
function pptxSetOutline(data) {
_pptxOutline = data;
_pptxOutlineEdits = {};
pptxRenderOutlineCards();
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = false; genBtn.style.opacity = '1'; }
if (sidebarTab === 'pptx') setPptxSbTab('outline');
pptxSetStatus(`아웃라인 준비 (${(data.slides || []).length}장)`);
}
const SLIDE_TYPE_ICON = { title:'🏷', section:'📂', content:'📝', image:'🖼', table:'📊', chart:'📈', timeline:'⏳', compare:'⚖' };
function pptxRenderOutlineCards() {
const container = document.getElementById('psb-outline-cards');
if (!container || !_pptxOutline) return;
const slides = _pptxOutline.slides || [];
if (!slides.length) {
container.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:8px">슬라이드가 없습니다</div>';
return;
}
container.innerHTML = slides.map((s, i) => {
const icon = SLIDE_TYPE_ICON[s.type] || '📄';
const imgThumb = s.image_url
? `<img src="${escHtml(s.image_url)}" style="width:40px;height:30px;object-fit:cover;border-radius:4px;flex-shrink:0" loading="lazy">`
: `<div style="width:40px;height:30px;border-radius:4px;background:var(--panel);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px">🖼</div>`;
const bulletsPreview = Array.isArray(s.bullets) && s.bullets.length
? `<div style="font-size:10px;color:var(--muted);margin-top:3px;overflow:hidden;max-height:32px">${s.bullets.slice(0,2).map(b => `• ${b}`).join('<br>')}</div>`
: '';
return `<div class="psb-slide-card" style="border:1px solid var(--line);border-radius:8px;padding:7px 8px;background:var(--panel-2);cursor:pointer" onclick="pptxToggleEdit(${i})">
<div style="display:flex;align-items:center;gap:7px">
<span style="font-size:10px;color:var(--muted);flex-shrink:0;width:18px;text-align:right">${i+1}</span>
${imgThumb}
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${icon} ${escHtml(s.title || '(제목 없음)')}</div>
<div style="font-size:10px;color:var(--muted)">${s.type}</div>
${bulletsPreview}
</div>
</div>
<div id="psb-edit-${i}" style="display:none;margin-top:7px;border-top:1px solid var(--line);padding-top:7px">
<input type="text" value="${escHtml(s.title || '')}" oninput="pptxEditTitle(${i}, this.value)"
style="width:100%;box-sizing:border-box;font-size:12px;padding:4px 6px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;margin-bottom:4px">
${Array.isArray(s.bullets) ? `<textarea rows="3" oninput="pptxEditBullets(${i}, this.value)"
style="width:100%;box-sizing:border-box;font-size:11px;padding:4px 6px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px;resize:vertical;font-family:'IBM Plex Mono',monospace">${escHtml((s.bullets||[]).join('\n\n'))}</textarea>` : ''}
</div>
</div>`;
}).join('');
}
function pptxToggleEdit(idx) {
const el = document.getElementById('psb-edit-' + idx);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function pptxEditTitle(idx, val) {
if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {};
_pptxOutlineEdits[idx].title = val;
}
function pptxEditBullets(idx, val) {
if (!_pptxOutlineEdits[idx]) _pptxOutlineEdits[idx] = {};
_pptxOutlineEdits[idx].bullets = val.split(/\n\n+/).map(g => g.replace(/^\n+|\n+$/g, '')).filter(g => g.trim() !== '');
}
function pptxGenerate() {
if (!_pptxOutline) return;
// Apply edits to outline
const slides = (_pptxOutline.slides || []).map((s, i) => {
const ed = _pptxOutlineEdits[i];
if (!ed) return s;
return Object.assign({}, s, ed.title !== undefined ? { title: ed.title } : {}, ed.bullets !== undefined ? { bullets: ed.bullets } : {});
});
const edited = Object.assign({}, _pptxOutline, { slides });
const hasEdits = Object.keys(_pptxOutlineEdits).length > 0;
const msg = hasEdits
? `아웃라인을 수정했습니다. 수정된 아웃라인으로 create_presentation을 호출해주세요:\n\`\`\`json\n${JSON.stringify(edited, null, 2)}\n\`\`\``
: `아웃라인 확인했습니다. create_presentation으로 슬라이드를 생성해주세요.`;
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
pptxSetStatus('생성 중...');
const input = document.getElementById('chat-input');
if (input) { input.value = msg; sendChat(); }
}
function pptxResetWorkflow() {
_pptxImages = [];
_pptxOutline = null;
_pptxOutlineEdits = {};
pptxRenderImageGrid();
const cardsEl = document.getElementById('psb-outline-cards');
if (cardsEl) cardsEl.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 후 표시됩니다</div>';
const genBtn = document.getElementById('psb-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.style.opacity = '.5'; }
const countEl = document.getElementById('psb-img-count');
if (countEl) countEl.textContent = '';
pptxSetStatus('');
setPptxSbTab('setup');
}
+71
View File
@@ -110,6 +110,7 @@
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">🧩 스킬</button>
<button class="sidebar-tab" id="tab-code" onclick="setSidebarTab('code')">💻 코드</button>
<button class="sidebar-tab" id="tab-pptx" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작 마법사">📊 슬라이드</button>
</div>
<!-- SESSIONS TAB -->
@@ -236,6 +237,76 @@
</div>
</div>
<!-- PPTX SIDEBAR -->
<div id="sidebar-pptx" style="display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden">
<!-- inner tabs -->
<div style="display:flex;border-bottom:1px solid var(--line);flex-shrink:0;background:var(--panel-2)">
<button class="code-sb-tab active" id="psb-tab-setup" onclick="setPptxSbTab('setup')">설정</button>
<button class="code-sb-tab" id="psb-tab-images" onclick="setPptxSbTab('images')">이미지 <span id="psb-img-count"></span></button>
<button class="code-sb-tab" id="psb-tab-outline" onclick="setPptxSbTab('outline')">아웃라인</button>
</div>
<!-- 설정 탭 -->
<div id="psb-setup" style="flex:1;overflow-y:auto;padding:14px 12px;display:flex;flex-direction:column;gap:10px">
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px">주제</div>
<input type="text" id="psb-topic" placeholder="예: 상악동 거상술 최신 동향"
style="width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px">
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px">PMCIDs <span style="font-weight:400;text-transform:none">(줄바꿈 또는 쉼표)</span></div>
<textarea id="psb-pmcids" rows="4" placeholder="PMC8273047&#10;PMC10340512"
style="width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:'IBM Plex Mono',monospace;resize:vertical"></textarea>
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px">템플릿</div>
<select id="psb-template"
style="width:100%;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer">
<option value="dark">dark</option>
<option value="business">business</option>
<option value="minimal">minimal</option>
<option value="creative">creative</option>
<option value="pastel">pastel</option>
<option value="warm">warm</option>
</select>
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px">슬라이드 수 (선택)</div>
<input type="number" id="psb-slide-count" min="5" max="30" placeholder="자동"
style="width:100%;box-sizing:border-box;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px">
</div>
<button onclick="pptxStartWorkflow()" id="psb-start-btn"
style="padding:9px;background:var(--brand);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:700">
▶ 시작
</button>
<div id="psb-status" style="font-size:11px;color:var(--muted);min-height:16px"></div>
</div>
<!-- 이미지 탭 -->
<div id="psb-images" style="display:none;flex:1;overflow-y:auto;padding:10px">
<div id="psb-image-grid" style="display:grid;grid-template-columns:repeat(2,1fr);gap:5px">
<div style="grid-column:1/-1;color:var(--muted);font-size:11px;padding:8px;text-align:center">이미지 추출 후 표시됩니다</div>
</div>
</div>
<!-- 아웃라인 탭 -->
<div id="psb-outline" style="display:none;flex-direction:column;flex:1;min-height:0">
<div id="psb-outline-cards" style="flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:5px">
<div style="color:var(--muted);font-size:11px;padding:8px;text-align:center">아웃라인 생성 후 표시됩니다</div>
</div>
<div style="padding:8px 10px;border-top:1px solid var(--line);flex-shrink:0;display:flex;gap:6px">
<button onclick="pptxGenerate()" id="psb-gen-btn" disabled
style="flex:1;padding:8px;background:var(--brand);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:700;opacity:.5">
✅ 슬라이드 생성
</button>
<button onclick="pptxResetWorkflow()" title="초기화"
style="padding:8px 10px;background:var(--panel-2);color:var(--muted);border:1px solid var(--line);border-radius:8px;cursor:pointer;font-size:12px">
↺
</button>
</div>
</div>
</div>
<!-- SERVER LOG PANEL -->
<div id="sidebar-log-panel">
<div id="sidebar-log-header" onclick="toggleLogPanel()" style="cursor:pointer">
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Runner</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap');
:root { --bg: #0c0c0c; --panel: #1a1a2e; --accent: #3b82f6; --green: #22c55e; --red: #ef4444; --muted: #6b7280; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:#e5e7eb; font-family:'IBM Plex Mono',monospace; height:100vh; display:flex; flex-direction:column; }
.header { display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--panel); border-bottom:1px solid #2d2d4a; flex-shrink:0; }
.header h1 { font-size:14px; font-weight:600; color:#e5e7eb; }
.header .file { font-size:12px; color:var(--accent); }
.status { font-size:11px; padding:2px 8px; border-radius:4px; font-weight:600; }
.status.loading { background:#1e3a5f; color:#60a5fa; }
.status.ready { background:#1a3a2a; color:var(--green); }
.status.running { background:#3a3a1a; color:#fbbf24; }
.status.error { background:#3a1a1a; color:var(--red); }
.toolbar { display:flex; gap:8px; margin-left:auto; }
.toolbar button { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:6px 14px; font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s; }
.toolbar button:hover { background:#353555; border-color:#4a4a7a; }
.toolbar button:disabled { opacity:.4; cursor:not-allowed; }
.toolbar button.run { background:#1a3a2a; border-color:#2d5a3a; color:var(--green); }
.toolbar button.run:hover { background:#224a3a; }
.output { flex:1; overflow:auto; padding:16px; font-size:13px; line-height:1.6; white-space:pre-wrap; word-break:break-all; }
.output .stdout { color:#e5e7eb; }
.output .stderr { color:var(--red); }
.output .info { color:var(--muted); font-style:italic; }
.footer { padding:8px 16px; border-top:1px solid #2d2d4a; font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; display:flex; justify-content:space-between; }
</style>
</head>
<body>
<div class="header">
<h1>🐍 Python</h1>
<span class="file" id="filename"></span>
<span class="status loading" id="status">Pyodide 로딩중...</span>
<div class="toolbar">
<button class="run" id="btn-run" disabled onclick="runCode()">▶ 실행</button>
</div>
</div>
<div class="output" id="output"><span class="info">Pyodide를 로딩하고 있습니다...</span></div>
<div class="footer">
<span id="footer-info">Pyodide (WebAssembly Python)</span>
<span id="exec-time"></span>
</div>
<script src="https://cdn.jsdelivr.net/pyodide/v0.27.3/full/pyodide.js"></script>
<script>
let pyodide = null;
let code = sessionStorage.getItem('py_run_code') || '';
const fileName = sessionStorage.getItem('py_run_name') || 'script.py';
sessionStorage.removeItem('py_run_code');
sessionStorage.removeItem('py_run_name');
document.getElementById('filename').textContent = fileName;
document.title = fileName + ' — Python Runner';
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status ' + cls;
}
function appendOutput(text, cls = 'stdout') {
const el = document.getElementById('output');
const span = document.createElement('span');
span.className = cls;
span.textContent = text;
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function clearOutput() {
document.getElementById('output').innerHTML = '';
}
async function initPyodide() {
try {
pyodide = await loadPyodide();
setStatus('준비', 'ready');
document.getElementById('btn-run').disabled = false;
const pyVer = pyodide.runPython('import sys; sys.version.split()[0]');
document.getElementById('footer-info').textContent = `Pyodide ${pyodide.version} (Python ${pyVer})`;
clearOutput();
if (code) {
appendOutput(`${fileName} 로드 완료 (${(new TextEncoder().encode(code).length / 1024).toFixed(1)}KB). ▶ 실행을 누르세요.\n`, 'info');
} else {
appendOutput('코드가 전달되지 않았습니다.\n', 'stderr');
setStatus('코드 없음', 'error');
}
} catch (e) {
setStatus('로딩 실패', 'error');
clearOutput();
appendOutput('Pyodide 로딩 실패:\n' + (e.stack || e.message || e) + '\n', 'stderr');
}
}
async function runCode() {
if (!pyodide) return;
if (!code) { appendOutput('실행할 코드가 없습니다.\n', 'stderr'); return; }
document.getElementById('btn-run').disabled = true;
clearOutput();
setStatus('실행중', 'running');
const t0 = performance.now();
try {
// Capture stdout/stderr
pyodide.runPython(`
import sys, io
_stdout_capture = io.StringIO()
_stderr_capture = io.StringIO()
sys.stdout = _stdout_capture
sys.stderr = _stderr_capture
`);
pyodide.runPython(code);
const stdout = pyodide.runPython('_stdout_capture.getvalue()');
const stderr = pyodide.runPython('_stderr_capture.getvalue()');
if (stdout) appendOutput(stdout);
if (stderr) appendOutput(stderr, 'stderr');
setStatus('완료', 'ready');
} catch (e) {
// Show full Python traceback
const tb = e.stack || e.message || String(e);
appendOutput(tb + '\n', 'stderr');
setStatus('오류', 'error');
} finally {
// Safely restore stdout
try {
pyodide.runPython(`
try:
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
except:
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
`);
} catch {}
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
document.getElementById('exec-time').textContent = `${elapsed}s`;
document.getElementById('btn-run').disabled = false;
}
}
initPyodide();
</script>
</body>
</html>