Add meteorologist skill with 9 weather/climate tools

New skill:
- .smallclaw/skills/meteorologist/SKILL.md — 기상 전문가 스킬 (Windy iframe 규칙 포함)

New weather tools (src/tools/weather.ts):
- weather_airpollution: OpenWeather 대기질 (AQI, PM2.5/PM10/O3/NO2)
- weather_openmeteo: Open-Meteo 시간별 예보 (ECMWF/GFS, 키 불필요)
- weather_kma: 기상청 공식 API (초단기실황·단기예보)
- weather_airkorea: 에어코리아 실시간 대기질
- weather_nasa_power: NASA POWER 기후 데이터 (MERRA-2, 키 불필요)
- weather_era5: ERA5 재분석 과거 데이터 (Open-Meteo Historical, 키 불필요)
- weather_cds: Copernicus CDS 정식 API (ERA5·CMIP6 SSP 시나리오)
- weather_cmip6: CMIP6 기후 모델 (Open-Meteo Climate API, 키 불필요)

Scripts:
- scripts/era5_cds_fetch.py — CDS/CMIP6 Python 헬퍼 (ZIP→NetCDF 처리 포함)

web-ui:
- Windy URL → iframe 자동 렌더링 (standalone URL + 마크다운 링크 모두 감지)
- windyToEmbedUrl: windy.com comma 형식 URL 파싱 개선

server-v2.ts:
- BROWSER RULE: "열어줘" 제거 → 오용 방지
- weather context: 9개 도구 선택 기준 추가
- JSON schema 8개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-26 14:22:49 +09:00
co-authored by Claude Sonnet 4.6
parent ed25f457df
commit d7eb0abc6d
18 changed files with 2545 additions and 126 deletions
+407 -19
View File
@@ -564,7 +564,7 @@ function setSidebarTab(tab) {
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
document.getElementById('sidebar-code').style.display = tab === 'code' ? 'flex' : 'none';
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
if (tab === 'code') { codeSbRefresh(); csbLoadModels(); }
if (tab === 'code') { codeSbRefresh(); csbLoadModels(); csbLoadCodeDir(); }
}
// ---- Agent mode toggle ----
@@ -1647,7 +1647,9 @@ async function sendChat(queuedMessage = null) {
if (event.url) {
pushProgressLine(`Image: ${event.alt || event.url}`);
const imgAlt = String(event.alt || '').replace(/"/g, '&quot;');
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" />`;
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
const imgOnerr = `this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; ${imgErrAlt}를 불러올 수 없습니다.</span>')`;
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" onerror="${imgOnerr}" />`;
// Append image to current streaming AI message
const lastAiMsg = chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'ai'
? chatHistory[chatHistory.length - 1] : null;
@@ -1746,6 +1748,7 @@ async function sendChat(queuedMessage = null) {
}
isThinking = false;
codeMonacoThrottleFlush();
if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }
streamingSessionId = null; // release session lock
currentAbortController = null;
@@ -5452,6 +5455,83 @@ function toggleTxtEmbedBtn(btn) {
}
}
function windyToEmbedUrl(url) {
try {
var u = new URL(url);
// Already an embed URL — use as-is
if (u.hostname === 'embed.windy.com') return url;
// windy.com map URL → convert to embed
if (/windy\.com/.test(u.hostname)) {
var lat = u.searchParams.get('lat') || '';
var lon = u.searchParams.get('lon') || '';
var zoom = u.searchParams.get('zoom') || '';
var overlay = u.searchParams.get('overlay') || '';
var level = u.searchParams.get('level') || 'surface';
// Handle windy.com comma format: ?overlay,lat,lon,zoom or pathname /-overlay?overlay,lat,lon,zoom
if (!lat || !lon) {
var qs = u.search.slice(1); // e.g. 'radar,36.545,127.974,7'
var parts = qs.split(',');
if (parts.length >= 3) {
overlay = overlay || parts[0];
lat = parts[1];
lon = parts[2];
zoom = zoom || parts[3] || '7';
}
}
lat = lat || '37.5';
lon = lon || '126.9';
zoom = zoom || '7';
overlay = overlay || 'rain';
// Map radar overlay name
if (overlay === '-radar' || overlay === 'radar') overlay = 'radar';
return 'https://embed.windy.com/embed2.html?lat=' + lat + '&lon=' + lon +
'&zoom=' + zoom + '&level=' + level + '&overlay=' + overlay +
'&menu=&message=true&marker=&calendar=now&pressure=&type=map' +
'&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C';
}
return null;
} catch(e) { return null; }
}
function youtubeToEmbedUrl(url) {
try {
var u = new URL(url);
var id = null;
if (u.hostname === 'youtu.be') {
id = u.pathname.slice(1).split(/[/?]/)[0];
} else if (/youtube\.com/.test(u.hostname)) {
id = u.searchParams.get('v');
if (!id) {
var m = u.pathname.match(/\/(?:shorts|embed)\/([^/?]+)/);
if (m) id = m[1];
}
}
if (!id) return null;
var start = u.searchParams.get('t') || u.searchParams.get('start');
return 'https://www.youtube.com/embed/' + id + (start ? '?start=' + parseInt(start, 10) : '');
} catch(e) { return null; }
}
function mapsToEmbedUrl(url) {
try {
var u = new URL(url);
var q = null;
var placeMatch = u.pathname.match(/\/maps\/place\/([^/@?]+)/);
if (placeMatch) q = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
if (!q) {
var searchMatch = u.pathname.match(/\/maps\/search\/([^/@?]+)/);
if (searchMatch) q = decodeURIComponent(searchMatch[1].replace(/\+/g, ' '));
}
if (!q) {
var coordMatch = u.pathname.match(/\/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (coordMatch) q = coordMatch[1] + ',' + coordMatch[2];
}
if (!q) q = u.searchParams.get('q') || u.searchParams.get('query');
if (!q) return null;
return 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&output=embed&hl=ko';
} catch(e) { return null; }
}
function renderMarkdown(text, opts) {
if (!text) return '';
@@ -5472,8 +5552,18 @@ function renderMarkdown(text, opts) {
});
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) {
var trimmed = code.trim();
if (lang === 'midi-widget') {
try {
var mwRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'");
var mwData = JSON.parse(mwRaw);
var mwFilename = mwData.filename || (mwData.saved_to ? mwData.saved_to.split('/').pop() : 'score.mid');
return buildMidiPickerWidget(mwData, mwFilename).html;
} catch(e) {
return '<pre class="md-code-block"><code>' + escHtml(trimmed) + '</code></pre>';
}
}
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
@@ -5523,6 +5613,32 @@ function renderMarkdown(text, opts) {
'</div>' +
'</div>';
}
if (lang === 'kstock') {
var ksRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').trim();
var ksParts = ksRaw.split('|');
var ksCode = ksParts[0].trim().replace(/\D/g,''); // 숫자만 (005930)
var ksPeriod = (ksParts[1] || 'day').trim().toLowerCase();
var ksPeriodMap = { '1d':'day','day':'day','1w':'week','week':'week','1m':'month','month':'month','3m':'3month','3month':'3month','1y':'year','year':'year','3y':'3year','3year':'3year' };
var ksNaverPeriod = ksPeriodMap[ksPeriod] || 'day';
var ksPeriodLabel = { day:'일봉', week:'주봉', month:'월봉', '3month':'3개월', year:'1년', '3year':'3년' }[ksNaverPeriod] || ksNaverPeriod;
if (!ksCode) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var ksId = 'ks-' + Math.random().toString(36).slice(2, 9);
var ksImgUrl = 'https://ssl.pstatic.net/imgfinance/chart/item/area/' + ksNaverPeriod + '/' + ksCode + '.png';
var ksNaverUrl = 'https://finance.naver.com/item/main.naver?code=' + ksCode;
return '<div class="music-widget" id="' + ksId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(ksCode) + ' · ' + escHtml(ksPeriodLabel) + ' · 네이버금융</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<a href="' + ksNaverUrl + '" target="_blank" style="font-size:12px;color:var(--muted);text-decoration:none">네이버금융 ↗</a>' +
'<button onclick="toggleMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body" style="padding:8px;text-align:center">' +
'<img src="' + ksImgUrl + '" alt="' + escHtml(ksCode) + ' 차트" style="max-width:100%;border-radius:4px" onerror="this.parentNode.innerHTML=\'<span style=\\\"color:var(--muted);font-size:13px\\\">차트를 불러올 수 없습니다.</span>\'">' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
@@ -5531,7 +5647,10 @@ function renderMarkdown(text, opts) {
// Images: ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
return '<img class="md-img" src="' + url + '" alt="' + alt + '" loading="lazy" onclick="openImageZoom(this.src)" />';
var safeAlt = alt.replace(/"/g, '&quot;');
var errAlt = (alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; " + errAlt + "를 불러올 수 없습니다.</span>')";
return '<img class="md-img" src="' + url + '" alt="' + safeAlt + '" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />';
});
// Links: [text](url) — render download links for file types as buttons, PPTX gets preview
@@ -5544,6 +5663,48 @@ function renderMarkdown(text, opts) {
return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url, opts);
});
// YouTube URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:(?:www\.|m\.)?youtube\.com\/(?:watch|shorts|embed)|youtu\.be\/)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = youtubeToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="yt-embed-wrap"><iframe class="yt-embed" src="' + safeEmbed + '" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">YouTube에서 열기 ↗</a></div></div>' + post;
});
// Google Maps URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:www\.google\.com\/maps|maps\.google\.com|goo\.gl\/maps)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = mapsToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="map-embed-wrap"><iframe class="map-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Google 지도에서 열기 ↗</a></div></div>' + post;
});
// Windy URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>' + post;
});
// Windy URLs inside anchor tags (markdown links) → inline iframe
html = html.replace(/<a\s[^>]*href="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*>[^<]*<\/a>/gi, function(m, url) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
});
// Bare image URLs on their own line → inline image
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/[^\s<>"()\n#?]+\.(?:jpe?g|png|gif|webp|svg|bmp|avif|tiff)(?:[?#][^\s<>"()\n]*)?)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; 이미지를 불러올 수 없습니다.</span>')";
return pre + '<img class="md-img" src="' + url + '" alt="" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />' + post;
});
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
@@ -5796,6 +5957,9 @@ function buildMusicActions(data, downloadName) {
var pdfUrl = data.pdf_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var html = '<div class="music-actions">';
if (data.saved_to) {
html += '<div style="font-size:11px;color:var(--muted);margin-bottom:6px;width:100%">💾 ' + escHtml(data.saved_to) + '</div>';
}
if (midiUrl) {
html += '<div class="midi-router-panel" id="' + rid + '" style="display:none;margin-bottom:4px">'
+ '<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>'
@@ -8500,7 +8664,8 @@ function initCodeView() {
tabSize: 2,
});
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
if (!codeFiles.length) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n');
codeFlushPendingQueue();
if (!codeFiles.length && !codePendingQueue.length) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n');
else renderCodeFileTabs();
setupCodeVResize();
csbRestoreSettings();
@@ -8528,6 +8693,7 @@ function switchCodeFile(id) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
renderCodeFileTabs();
codeUpdateRunButton();
}
function closeCodeFile(id, ev) {
@@ -8557,6 +8723,35 @@ function renderCodeFileTabs() {
}
const CODE_DIR = 'code';
let codeDirLabel = 'code';
function csbCodeDirChanged(val) { /* preview only, saved on button click */ }
async function csbCodeDirSave() {
const input = document.getElementById('csb-codedir');
const val = (input?.value || '').trim() || 'code';
try {
await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ code_dir: val }) });
codeDirLabel = val;
const folderBtn = document.getElementById('code-folder-btn');
if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${val}/`;
const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]');
if (headerLabel) headerLabel.textContent = `workspace/${val}/`;
codeSbRefresh();
} catch {}
}
async function csbLoadCodeDir() {
try {
const data = await api('/api/settings/paths');
const dir = data.code_dir || 'code';
codeDirLabel = dir;
const input = document.getElementById('csb-codedir');
if (input) input.value = dir;
const folderBtn = document.getElementById('code-folder-btn');
if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${dir}/`;
const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]');
if (headerLabel) headerLabel.textContent = `workspace/${dir}/`;
} catch {}
}
function codeSaveFile() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
@@ -8801,19 +8996,145 @@ document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === '`') { e.preventDefault(); if (document.getElementById('code-view')?.classList.contains('active')) toggleCodeTerminal(); }
});
function codeRunInTerminal() {
// ── Code Run: multi-mode execution ──────────────────────────────────────────
const CODE_RUN_MODES = {
html: [
{ id: 'browser', icon: '🌐', label: '브라우저', desc: '새 탭에서 열기' },
{ id: 'terminal', icon: '⌨', label: '터미널', desc: '웹 서버로 실행' },
],
htm: 'html',
py: [
{ id: 'pyodide', icon: '🐍', label: 'Pyodide', desc: '브라우저에서 실행' },
{ id: 'terminal', icon: '⌨', label: 'python3', desc: '터미널에서 실행' },
],
js: [
{ id: 'terminal', icon: '⌨', label: 'node', desc: '터미널에서 실행' },
],
ts: [
{ id: 'terminal', icon: '⌨', label: 'ts-node', desc: '터미널에서 실행' },
],
go: [
{ id: 'terminal', icon: '⌨', label: 'go run', desc: '터미널에서 실행' },
],
rs: [
{ id: 'terminal', icon: '⌨', label: 'rustc', desc: '터미널에서 실행' },
],
cpp: [
{ id: 'terminal', icon: '⌨', label: 'g++', desc: '터미널에서 실행' },
],
c: [
{ id: 'terminal', icon: '⌨', label: 'gcc', desc: '터미널에서 실행' },
],
sh: [
{ id: 'terminal', icon: '⌨', label: 'bash', desc: '터미널에서 실행' },
],
};
function codeGetRunModes(ext) {
const modes = CODE_RUN_MODES[ext];
if (!modes) return [{ id: 'terminal', icon: '⌨', label: '터미널', desc: '터미널에서 실행' }];
if (typeof modes === 'string') return CODE_RUN_MODES[modes];
return modes;
}
let _codeRunMenuOpen = false;
function codeToggleRunMenu() {
const menu = document.getElementById('code-run-menu');
if (_codeRunMenuOpen) {
menu.style.display = 'none';
_codeRunMenuOpen = false;
return;
}
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
menu.innerHTML = modes.map(m =>
`<div onclick="codeRunWith('${m.id}')" style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#2d4a2d'" onmouseout="this.style.background=''">` +
`<span style="font-size:14px">${m.icon}</span>` +
`<div style="flex:1"><div style="font-size:12px;font-weight:600;color:#e5e7eb">${m.label}</div><div style="font-size:10px;color:#9ca3af">${m.desc}</div></div>` +
`</div>`
).join('');
menu.style.display = 'block';
_codeRunMenuOpen = true;
const close = (e) => {
if (!menu.contains(e.target) && !e.target.closest('#code-run-dropdown')) {
menu.style.display = 'none';
_codeRunMenuOpen = false;
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
function codeUpdateRunButton() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
const iconEl = document.getElementById('code-run-icon');
const labelEl = document.getElementById('code-run-label');
if (!file || !iconEl) return;
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
const primary = modes[0];
iconEl.textContent = primary.icon;
labelEl.textContent = primary.label;
}
function codeRunDefault() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
file.content = monacoEditor.getValue();
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
codeRunWith(modes[0].id);
}
function codeRunWith(mode) {
const menu = document.getElementById('code-run-menu');
if (menu) menu.style.display = 'none';
_codeRunMenuOpen = false;
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
file.content = monacoEditor.getValue();
const fpath = `${CODE_DIR}/${file.name}`;
const cmd = ext === 'py' ? `python3 ${fpath}` : ext === 'js' ? `node ${fpath}` :
ext === 'ts' ? `npx ts-node ${fpath}` : ext === 'go' ? `go run ${fpath}` :
ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'sh' ? `bash ${fpath}` : `./${fpath}`;
// Open code terminal and run
if (mode === 'browser') {
api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }) })
.then(r => {
if (r.success) {
const w = window.open(`${location.origin}/code-serve/${CODE_DIR}/${file.name}`, '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
}
});
return;
}
if (mode === 'pyodide') {
sessionStorage.setItem('py_run_code', file.content);
sessionStorage.setItem('py_run_name', file.name);
const w = window.open('/python-runner.html', '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
return;
}
// terminal modes
const ext = file.name.split('.').pop().toLowerCase();
let cmd;
if (mode === 'terminal') {
cmd = ext === 'py' ? `python3 ${fpath}` :
ext === 'js' ? `node ${fpath}` :
ext === 'ts' ? `npx ts-node ${fpath}` :
ext === 'go' ? `go run ${fpath}` :
ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'sh' ? `bash ${fpath}` : `./${fpath}`;
} else {
cmd = mode === 'python3' ? `python3 ${fpath}` :
mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`;
}
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
@@ -8835,6 +9156,50 @@ function codeSendSelectionToAI() {
// ─── V2 Chat Live Code Streaming ────────────────────────────────────────────────
// Global state for streaming code into the editor during the main v2 chat.
let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false;
let codePendingQueue = []; // [{fname, content, lang}] — queued when Monaco not ready
// Throttled Monaco model update — avoids freezing UI by limiting setValue calls to ~80ms intervals
let _monacoThrottleTimer = null;
let _monacoThrottleModel = null;
let _monacoThrottleValue = '';
function codeMonacoThrottle(model, value) {
_monacoThrottleModel = model;
_monacoThrottleValue = value;
if (_monacoThrottleTimer) return; // already scheduled
_monacoThrottleTimer = setTimeout(() => {
_monacoThrottleTimer = null;
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
_monacoThrottleModel.setValue(_monacoThrottleValue);
_monacoThrottleModel = null;
}
}, 80);
}
function codeMonacoThrottleFlush() {
if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; }
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
_monacoThrottleModel.setValue(_monacoThrottleValue);
}
_monacoThrottleModel = null;
}
function codeFlushPendingQueue() {
if (!monacoReady || !monacoEditor || !codePendingQueue.length) return;
for (const item of codePendingQueue) {
const existing = codeFiles.find(f => f.name === item.fname);
if (existing) {
if (existing.model) existing.model.setValue(item.content);
else existing.content = item.content;
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname));
codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model });
if (!v2LiveFileId) v2LiveFileId = id;
}
}
codePendingQueue = [];
if (v2LiveFileId) switchCodeFile(v2LiveFileId);
}
function v2LiveCodeUpdate(fullReply) {
if (typeof codeIsOpenable !== 'function') return;
const fenceRe = /```([\w\-]*)\s*\n/g;
@@ -8853,19 +9218,26 @@ function v2LiveCodeUpdate(fullReply) {
if (!codeIsOpenable(code, lang)) return;
v2LiveInBlock = true;
v2LiveLang = lang;
const fname = codeDeriveName(code, lang);
if (monacoReady && monacoEditor) {
const fname = codeDeriveName(code, lang);
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
v2LiveFileId = id;
switchCodeFile(id);
} else {
// Monaco not ready yet — queue and trigger init
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (v2LiveFileId) {
const file = codeFiles.find(f => f.id === v2LiveFileId);
if (file?.model) {
file.model.setValue(code);
codeMonacoThrottle(file.model, code);
}
} else if (codePendingQueue.length) {
// Monaco still loading — update queued content
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
@@ -8921,20 +9293,25 @@ async function codeAiSend() {
if (!codeIsOpenable(code, lang)) return;
liveInBlock = true;
liveLang = lang;
const fname = codeDeriveName(code, lang);
if (monacoReady && monacoEditor) {
const fname = codeDeriveName(code, lang);
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
liveFileId = id;
switchCodeFile(id);
} else {
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (liveFileId) {
const file = codeFiles.find(f => f.id === liveFileId);
if (file?.model) {
file.model.setValue(code);
liveCodeBuf = code;
codeMonacoThrottle(file.model, code);
}
} else if (codePendingQueue.length) {
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
@@ -8955,11 +9332,21 @@ async function codeAiSend() {
else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool_call / tool_result events for live editor update
else if (ev.type === 'tool_call' && ev.action === 'create_file') {
const cf = ev.args?.filename || ev.args?.name || '';
const cc = ev.args?.content || '';
if (cf && cc) codeEditorWriteFile(cf.replace(/^code\//, ''), cc);
}
else if (ev.type === 'tool_result' && ev.action === 'create_file' && !ev.error) {
codeSbRefresh();
}
if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); codeAiLiveUpdate(reply); }
} catch {}
}
}
liveInBlock = false; liveFileId = null;
codeMonacoThrottleFlush();
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
else codeAiRenderMarkdown(thinkEl, reply);
@@ -9084,8 +9471,8 @@ function codeDeriveName(code, lang) {
// creating or updating the tab. Used to mirror the model's create_file in real time.
function codeEditorWriteFile(fname, content) {
if (!monacoReady || !monacoEditor) {
codePendingQueue.push({ fname, content, lang: codeDetectLang(fname) });
initCodeView();
setTimeout(() => codeEditorWriteFile(fname, content), 800);
return;
}
const existing = codeFiles.find(f => f.name === fname);
@@ -9101,8 +9488,9 @@ function codeEditorWriteFile(fname, content) {
function codeAiOpenInEditor(code, lang) {
if (!codeIsOpenable(code, lang)) return;
if (!monacoReady || !monacoEditor) {
const fname = codeDeriveName(code, lang);
codePendingQueue.push({ fname, content: code, lang });
initCodeView();
setTimeout(() => codeAiOpenInEditor(code, lang), 800);
return;
}
const fname = codeDeriveName(code, lang);
+18 -2
View File
@@ -23,9 +23,9 @@
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
</head>
<body>
@@ -164,6 +164,15 @@
<!-- 설정 탭 -->
<div id="csb-settings" style="display:none;flex-direction:column;flex:1;min-height:0;overflow-y:auto;padding:14px 12px;gap:14px">
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">코드 저장 디렉토리</div>
<div style="display:flex;align-items:center;gap:6px">
<input type="text" id="csb-codedir" value="code" onchange="csbCodeDirChanged(this.value)"
style="flex:1;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">
<button onclick="csbCodeDirSave()" title="저장" style="background:var(--brand);color:#fff;border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap">저장</button>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">워크스페이스 하위 폴더 (기본: code)</div>
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">폰트 크기</div>
<div style="display:flex;align-items:center;gap:8px">
@@ -351,7 +360,14 @@
📁 <span>workspace/code/</span> ▾
</button>
<button class="code-tool-btn btn-ai" onclick="codeSendSelectionToAI()" title="선택 영역을 AI에게 전송">💬 AI에게 전송</button>
<button class="code-tool-btn btn-run" onclick="codeRunInTerminal()" title="터미널에서 실행">▶ 실행</button>
<div id="code-run-dropdown" style="position:relative;display:inline-flex">
<button class="code-tool-btn btn-run" onclick="codeRunDefault()" title="실행">
<span id="code-run-icon">▶</span> <span id="code-run-label">실행</span>
</button>
<button class="code-tool-btn btn-run" onclick="codeToggleRunMenu()" title="실행 방법 선택" style="padding:6px 4px;margin-left:-1px;border-left:1px solid rgba(34,197,94,.3)">▾</button>
<div id="code-run-menu" style="display:none;position:absolute;top:100%;right:0;z-index:99;min-width:180px;background:#1a2e1a;border:1px solid #2d5a3a;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.5);margin-top:2px;overflow:hidden">
</div>
</div>
<button class="code-tool-btn btn-save" onclick="codeSaveFile()" title="파일 저장 (Ctrl+S)">💾 저장</button>
<button id="code-term-toggle-btn" class="code-tool-btn" onclick="toggleCodeTerminal()"
title="터미널 토글 (Ctrl+`)"
+10
View File
@@ -763,6 +763,16 @@
.msg.user .msg-content { color: #0f3d8a; }
.md-img { max-width: 100%; max-height: 400px; border-radius: 8px; cursor: pointer; margin: 8px 0; display: block; }
.md-img:hover { opacity: 0.9; }
.md-img-err { display: inline-block; color: var(--muted, #888); font-size: 13px; padding: 4px 8px; background: var(--surface2, #f0f0f0); border-radius: 6px; margin: 4px 0; }
.map-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.map-embed { width: 100%; height: 420px; border: none; display: block; }
.map-embed-link { padding: 6px 12px; background: var(--surface2, #f5f5f7); font-size: 12px; }
.map-embed-link a { color: var(--accent, #1a73e8); text-decoration: none; }
.map-embed-link a:hover { text-decoration: underline; }
.yt-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.yt-embed { width: 100%; aspect-ratio: 16/9; border: none; display: block; }
.windy-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.windy-embed { width: 100%; height: 500px; border: none; display: block; }
.md-code-block { background: #1e1e2e; color: #cdd6f4; padding: 12px 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; margin: 8px 0; }
.md-inline-code { background: #e8edf6; color: #17243b; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
:root[data-theme="dark"] .md-inline-code { background: #2a2a3e; color: #cdd6f4; }