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:
+217
-2
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// --- 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
return String(str).replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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
@@ -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>
|
||||
Reference in New Issue
Block a user