v3.0.8: 스킬앱 독립화 + 치과에이전트 + 음악/MCP/보이스 라우트 + DB 워크플로 개선

- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent
  각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거
- 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice
- skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입
- 세션 삭제 API: DELETE /api/admin/sessions/:username/:id
- Telegram defaultUsername: 어드민 계정 자동 추론
- Ollama /api/ollama/models에 vision 지원 여부 플래그 추가
- DB 워크플로 개선: workflow_images.py 대규모 리팩터링
- 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄)
- 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-21 17:07:58 +09:00
co-authored by Claude Sonnet 4.6
parent 0dae10693f
commit 9ad63ec8b1
131 changed files with 8980 additions and 3158 deletions
File diff suppressed because one or more lines are too long
+124 -88
View File
@@ -13,7 +13,7 @@ const AGENT_SESSION_KEY_BASE = 'cherryclaw_agent_session_id';
const THEME_KEY = 'cherryclaw_theme';
const TOKEN_KEY = 'smallclaw_token';
function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } }
function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); } catch {} }
function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; }
function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; }
@@ -106,6 +106,63 @@ function applyLogFilters() {
if (noneBtn) noneBtn.classList.toggle('active', logLevelFilter.has('__none__'));
}
let logSearchQuery = '';
function onLogSearch(q) {
logSearchQuery = q.trim().toLowerCase();
const clearBtn = document.getElementById('log-search-clear');
const countEl = document.getElementById('log-search-count');
if (clearBtn) clearBtn.style.display = logSearchQuery ? '' : 'none';
const body = document.getElementById('sidebar-log-body');
if (!body) return;
const lines = body.querySelectorAll('.log-line');
let matchCount = 0;
lines.forEach(line => {
const msgEl = line.querySelector('.log-msg');
const tsEl = line.querySelector('.log-ts');
if (!msgEl) return;
if (!logSearchQuery) {
// 검색 없음: 원래 텍스트 복원, no-match 제거
line.classList.remove('no-match');
if (msgEl._origText !== undefined) {
msgEl.textContent = msgEl._origText;
delete msgEl._origText;
}
return;
}
const orig = msgEl._origText !== undefined ? msgEl._origText : msgEl.textContent;
msgEl._origText = orig;
const tsText = tsEl ? tsEl.textContent : '';
const haystack = (orig + ' ' + tsText).toLowerCase();
const matches = haystack.includes(logSearchQuery);
line.classList.toggle('no-match', !matches);
if (matches) {
matchCount++;
// 하이라이트: 대소문자 무시 치환
const re = new RegExp(logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
msgEl.innerHTML = orig.replace(re, m => `<span class="log-hl">${m}</span>`);
} else {
msgEl.textContent = orig;
}
});
if (countEl) {
if (logSearchQuery) {
countEl.textContent = `${matchCount}건`;
countEl.style.display = '';
countEl.style.color = matchCount === 0 ? '#ef4444' : 'var(--muted)';
} else {
countEl.style.display = 'none';
}
}
}
function toggleLogPanel() {
const panel = document.getElementById('sidebar-log-panel');
if (!panel) return;
@@ -124,7 +181,11 @@ function clearLogPanel() {
if (body) body.innerHTML = '';
logLastTotalCount = 0;
logLevelCounts.log = 0; logLevelCounts.warn = 0; logLevelCounts.error = 0;
const inp = document.getElementById('log-search-input');
if (inp) inp.value = '';
logSearchQuery = '';
applyLogFilters();
onLogSearch('');
}
function renderLogEntries(entries) {
@@ -150,6 +211,7 @@ function renderLogEntries(entries) {
body.appendChild(frag);
if (logScrollLocked) body.scrollTop = body.scrollHeight;
applyLogFilters();
if (logSearchQuery) onLogSearch(logSearchQuery);
}
async function pollServerLogs() {
@@ -675,9 +737,10 @@ async function saveTask() {
// ---- Sidebar tabs ----
function setSidebarTab(tab) {
sidebarTab = tab;
document.getElementById('tab-jobs').classList.toggle('active', tab === 'jobs');
document.getElementById('tab-skills').classList.toggle('active', tab === 'skills');
document.getElementById('tab-pptx').classList.toggle('active', tab === 'pptx');
const toggle = (id, active) => document.getElementById(id)?.classList.toggle('active', active);
toggle('tab-jobs', tab === 'jobs');
toggle('tab-skills', tab === 'skills');
toggle('tab-pptx', 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-pptx').style.display = tab === 'pptx' ? 'flex' : 'none';
@@ -2422,7 +2485,7 @@ function setQuickThinkingEffort(level) {
function setSettingsTab(tab) {
settingsTab = tab;
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt', 'voice', 'session', 'context-viewer'];
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'voice', 'session', 'context-viewer'];
tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`);
@@ -2439,7 +2502,6 @@ function setSettingsTab(tab) {
if (t === 'agents') loadAgentsTab();
if (t === 'integrations') loadIntegrationsTab();
if (t === 'credentials') loadCredentialsTab();
if (t === 'ppt') loadPptTab();
if (t === 'users') loadUsersTab();
if (t === 'voice') loadVoiceSettings();
if (t === 'session') loadSessionSettings();
@@ -3324,16 +3386,17 @@ async function loadContextViewerTab() {
? '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px"></span>'
: '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--line-strong);margin-right:4px"></span>';
return `
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:var(--panel)">
<div id="cv-card-${escHtml(s.id)}" style="border:1px solid var(--line);border-radius:12px;padding:12px;background:var(--panel)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div style="display:flex;align-items:center;gap:8px">
${onlineDot}
<strong style="font-size:13px">${s.username}</strong>
<strong style="font-size:13px">${escHtml(s.username)}</strong>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted)">${s.id.slice(0,12)}…</span>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;color:var(--muted)">${lastActive}</span>
${pct >= 70 ? `<span style="font-size:11px;font-weight:700;color:${barColor};padding:2px 7px;border-radius:6px;background:${pct>=90?'#fee2e2':'#fff7ed'}">${pct}%</span>` : ''}
<button onclick="cvDeleteSession('${escHtml(s.username)}','${escHtml(s.id)}')" title="세션 삭제" style="background:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px;color:var(--muted);cursor:pointer" onmouseover="this.style.borderColor='var(--err)';this.style.color='var(--err)'" onmouseout="this.style.borderColor='var(--line)';this.style.color='var(--muted)'">✕</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
@@ -3354,91 +3417,26 @@ async function loadContextViewerTab() {
}
}
async function cvDeleteSession(username, sessionId) {
try {
const res = await fetch(`/api/admin/sessions/${encodeURIComponent(username)}/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
const card = document.getElementById('cv-card-' + sessionId);
if (card) card.remove();
} catch (e) {
alert('삭제 실패: ' + (e.message || e));
}
}
function escHtml(s) {
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 ---------------------------------------------------
var pptTemplates = [];
var pptSkins = [];
var selectedPptTemplate = 'business';
var selectedPptSkin = '';
async function loadPptTab() {
try {
const [tRes, sRes, cfg] = await Promise.all([
api('/api/ppt/templates'),
api('/api/ppt/skins'),
api('/api/settings/ppt').catch(() => ({})),
]);
pptTemplates = tRes.templates || [];
pptSkins = sRes.skins || [];
selectedPptTemplate = cfg.template || 'business';
selectedPptSkin = cfg.skin || '';
renderPptTemplates();
renderPptSkins();
} catch (e) { console.error('loadPptTab', e); }
}
function renderPptTemplates() {
var grid = document.getElementById('ppt-templates-grid');
if (!grid) return;
grid.innerHTML = pptTemplates.map(function(t) {
var sel = t.name.toLowerCase() === selectedPptTemplate;
var c = t.colors || {};
return '<div onclick="selectPptTemplate(\'' + t.name.toLowerCase() + '\')" style="cursor:pointer;border:2px solid ' + (sel ? c.accent || '#1668E3' : 'var(--line)') + ';border-radius:10px;padding:10px;background:' + (sel ? '#f0f6ff' : '#fff') + ';transition:all .15s">'
+ '<div style="font-weight:700;font-size:13px;margin-bottom:6px;color:' + (c.title || '#1A1A2E') + '">' + escHtml(t.name) + '</div>'
+ '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">' + escHtml(t.description || '') + '</div>'
+ '<div style="display:flex;gap:4px">'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.accent || '1668E3') + '"></span>'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.title || '1A1A2E') + '"></span>'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.subtitle || '5F6F86') + '"></span>'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.background || 'FFFFFF') + ';border:1px solid var(--line)"></span>'
+ '</div>'
+ (sel ? '<div style="margin-top:6px;font-size:10px;color:#0d4faf;font-weight:600">선택됨</div>' : '')
+ '</div>';
}).join('');
}
function renderPptSkins() {
var grid = document.getElementById('ppt-skins-grid');
if (!grid) return;
grid.innerHTML = '<div onclick="selectPptSkin(\'\')" style="cursor:pointer;border:2px solid ' + (selectedPptSkin === '' ? '#1668E3' : 'var(--line)') + ';border-radius:10px;padding:10px;background:#fff;text-align:center">'
+ '<div style="font-size:12px;color:var(--muted)">없음</div>'
+ (selectedPptSkin === '' ? '<div style="font-size:10px;color:#0d4faf;font-weight:600;margin-top:4px">선택됨</div>' : '')
+ '</div>'
+ pptSkins.map(function(s) {
var sel = s.name === selectedPptSkin;
return '<div onclick="selectPptSkin(\'' + escHtml(s.name) + '\')" style="cursor:pointer;border:2px solid ' + (sel ? '#1668E3' : 'var(--line)') + ';border-radius:10px;padding:8px;background:#fff;text-align:center">'
+ '<img src="/api/ppt/skins/' + encodeURIComponent(s.name) + '/thumb" style="width:100%;height:40px;object-fit:cover;border-radius:6px;margin-bottom:4px" />'
+ '<div style="font-size:11px;font-weight:600;color:var(--text)">' + escHtml(s.name) + '</div>'
+ (sel ? '<div style="font-size:10px;color:#0d4faf;font-weight:600">선택됨</div>' : '')
+ '</div>';
}).join('');
}
function selectPptTemplate(name) {
selectedPptTemplate = name;
renderPptTemplates();
savePptPrefs();
}
function selectPptSkin(name) {
selectedPptSkin = name;
renderPptSkins();
savePptPrefs();
}
async function savePptPrefs() {
try {
await fetch('/api/settings/ppt', {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ template: selectedPptTemplate, skin: selectedPptSkin }),
});
} catch (e) { console.error('savePptPrefs', e); }
}
function closeSettings() {
document.getElementById('settings-modal').style.display = 'none';
@@ -4442,6 +4440,20 @@ function setMeter(id, pct) {
el.style.width = meterWidth(pct);
}
function fmtNetSpeed(bps) {
if (bps >= 1024 * 1024 * 1024) return (bps / (1024 ** 3)).toFixed(1) + ' GB/s';
if (bps >= 1024 * 1024) return (bps / (1024 ** 2)).toFixed(1) + ' MB/s';
if (bps >= 1024) return (bps / 1024).toFixed(0) + ' KB/s';
return bps.toFixed(0) + ' B/s';
}
// 네트워크 바: 최근 최댓값 기준으로 스케일
let _netPeak = 1024 * 1024; // 초기 1 MB/s 기준
function netBarPct(bps) {
if (bps > _netPeak) _netPeak = bps;
return Math.min(100, (bps / _netPeak) * 100);
}
let systemStatsFetchInFlight = false;
async function loadSystemStats() {
if (systemStatsFetchInFlight) return;
@@ -4465,6 +4477,20 @@ async function loadSystemStats() {
setMeter('sys-gpu-meter', gpuAvailable ? gpu : 0);
setMeter('sys-vram-meter', gpuAvailable ? vram : 0);
// 사이드바 리소스 바
setText('sb-sys-cpu', fmtPercent(cpu));
setText('sb-sys-ram', fmtPercent(ram));
setText('sb-sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
setMeter('sb-sys-cpu-meter', cpu);
setMeter('sb-sys-ram-meter', ram);
setMeter('sb-sys-vram-meter', gpuAvailable ? vram : 0);
const rxBps = stats?.network?.rx_bps || 0;
const txBps = stats?.network?.tx_bps || 0;
setText('sb-sys-rx', fmtNetSpeed(rxBps));
setText('sb-sys-tx', fmtNetSpeed(txBps));
setMeter('sb-sys-rx-meter', netBarPct(rxBps));
setMeter('sb-sys-tx-meter', netBarPct(txBps));
const ollamaRunning = !!stats?.ollama_process?.running;
const ollamaCount = Number(stats?.ollama_process?.process_count || 0);
const ollamaMemMb = Number(stats?.ollama_process?.total_memory_mb || 0);
@@ -4516,6 +4542,16 @@ async function loadSystemStats() {
setMeter('sys-ram-meter', 0);
setMeter('sys-gpu-meter', 0);
setMeter('sys-vram-meter', 0);
setText('sb-sys-cpu', '--%');
setText('sb-sys-ram', '--%');
setText('sb-sys-vram', 'N/A');
setMeter('sb-sys-cpu-meter', 0);
setMeter('sb-sys-ram-meter', 0);
setMeter('sb-sys-vram-meter', 0);
setText('sb-sys-rx', '0 B/s');
setText('sb-sys-tx', '0 B/s');
setMeter('sb-sys-rx-meter', 0);
setMeter('sb-sys-tx-meter', 0);
} finally {
systemStatsFetchInFlight = false;
}
+380 -20
View File
@@ -497,6 +497,15 @@
</div>
</div>
<!-- ADC / Analog Input controls (AVR analogRead) -->
<div class="dht-section" id="adc-section" style="display:none">
<div class="dht-section-title">
<span class="dht-dot" id="adc-dot" style="background:#8b5cf6"></span>
<span>아날로그 입력 (A0–A5)</span>
</div>
<div id="adc-controls"></div>
</div>
<!-- MPU-6050 IMU controls -->
<div class="sensor-section" id="imu-section" style="display:none">
<div class="sensor-section-title">
@@ -789,7 +798,8 @@
let avr8jsReady = false;
let CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig;
timer0Config, timer1Config, timer2Config, twiConfig,
AVRADC, adcConfig, AVREEPROM, EEPROMMemoryBackend, eepromConfig;
try {
const mod = await import('./vendor/avr8js/index.js');
@@ -797,6 +807,7 @@ try {
({ CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig,
AVRADC, adcConfig, AVREEPROM, EEPROMMemoryBackend, eepromConfig,
avrInstruction } = mod);
if (typeof avrInstruction !== 'function') throw new Error('avrInstruction not exported');
window._avrInstruction = avrInstruction;
@@ -1203,6 +1214,122 @@ function setupSPI(cpu) {
portC.addListener(() => _tftUpdateCsDc());
}
// RP2040 SPI → TFT LCD 연결 (SPI0/SPI1 onTransmit 훅)
// schematic에서 CS/DC GPIO를 감지한 후 GPIO 리스너도 추가 (setupTFTFromSchematicRP2040에서)
function _setupRP2040SPI(sim) {
for (const spi of sim.rp2040.spi) {
spi.onTransmit = (value) => {
if (!tft.cs) tftSPIByte(value);
spi.completeTransmit(0);
};
}
}
// RP2040 스케치 코드 정적 분석 → TFT CS/DC 핀 자동 감지
function _rp2040AutoDetectTft(sim) {
const code = (typeof _esp32SketchCode === 'string') ? _esp32SketchCode : '';
if (!code) return;
// #define 매크로 값 수집
const defs = {};
for (const m of code.matchAll(/#define\s+(\w+)\s+(\d+)/g)) defs[m[1]] = parseInt(m[2]);
const rpin = s => { const n = parseInt(s); return isNaN(n) ? (defs[s] ?? -1) : n; };
let csGpio = -1, dcGpio = -1;
// 패턴 1: #define TFT_CS / TFT_DC (TFT_eSPI, Adafruit 공통)
for (const [k, v] of Object.entries(defs)) {
const ku = k.toUpperCase();
if ((ku.includes('TFT') || ku.includes('LCD') || ku.includes('DISP')) && ku.includes('CS') && csGpio < 0) csGpio = v;
if ((ku.includes('TFT') || ku.includes('LCD') || ku.includes('DISP')) && (ku.includes('DC') || ku.includes('RS')) && dcGpio < 0) dcGpio = v;
}
// 패턴 2: Adafruit_ST7735/ST7789/ILI9341 생성자 첫 두 인수 → CS, DC
if (csGpio < 0 || dcGpio < 0) {
const m = code.match(/Adafruit_(?:ST7735|ST7789|ILI9341)\s+\w+\s*\(\s*(\w+)\s*,\s*(\w+)/);
if (m) { if (csGpio < 0) csGpio = rpin(m[1]); if (dcGpio < 0) dcGpio = rpin(m[2]); }
}
if (csGpio < 0 || dcGpio < 0) return; // 감지 실패
// GPIO 리스너 및 TFT 섹션 활성화
const readPinHigh = g => { const p = sim.rp2040.gpio[g]; return p ? p.value === 1 : true; };
tft.cs = readPinHigh(csGpio);
tft.dc = readPinHigh(dcGpio);
if (sim.rp2040.gpio[csGpio]) sim.rp2040.gpio[csGpio].addListener(state => {
const nc = !!(state & 1);
if (!tft.cs && nc) { tft.inRamWr = false; tft.pendingNeed = 0; tft.pendingArgs = []; }
tft.cs = nc;
});
if (sim.rp2040.gpio[dcGpio]) sim.rp2040.gpio[dcGpio].addListener(state => { tft.dc = !!(state & 1); });
_rp2040TftListenersAdded = true;
const lbl = document.getElementById('tft-label');
if (lbl) lbl.textContent = `TFT · CS:GP${csGpio} DC:GP${dcGpio}`;
tft.active = true;
_tftShowSection();
console.log(`[RP2040] TFT 자동감지: CS=GP${csGpio}, DC=GP${dcGpio}`);
}
// RP2040 회로도 기반 TFT CS/DC GPIO 리스너 등록 (schUpdateNetlist에서 호출)
let _rp2040TftListenersAdded = false;
function _setupTFTFromSchematicRP2040() {
if (!_rp2040Sim) return;
const tftComps = SCH.components.filter(c => c.type === 'tft_lcd');
const sec = document.getElementById('tft-section');
if (!tftComps.length) return;
if (sec) sec.style.display = '';
const dot = document.getElementById('tft-dot');
const lbl = document.getElementById('tft-label');
if (dot) dot.classList.add('on');
const { find, pk } = _schBuildNets();
const comp = tftComps[0];
const csGpio = _schGpioFromComp(comp, 2, find, pk);
const dcGpio = _schGpioFromComp(comp, 4, find, pk);
const model = (comp.value || 'ST7735').toUpperCase().split(':')[0];
if (lbl) {
const parts = [model];
if (csGpio >= 0) parts.push('CS:GP' + csGpio);
if (dcGpio >= 0) parts.push('DC:GP' + dcGpio);
lbl.textContent = parts.join(' · ');
}
// GPIO 리스너 등록 + 현재 핀 상태 즉시 반영
// (리스너는 변경 시에만 발화하므로 초기 상태는 직접 읽어야 함)
const readPinHigh = (gpioN) => {
if (gpioN < 0 || gpioN > 29) return true;
const g = _rp2040Sim.rp2040.gpio[gpioN];
return g ? g.value === 1 : true; // GPIOPinState.High = 1
};
const addGpioListener = (gpioN, onChange) => {
if (gpioN < 0 || gpioN > 29) return;
const g = _rp2040Sim.rp2040.gpio[gpioN];
if (g) g.addListener(onChange);
};
_rp2040TftListenersAdded = false;
if (csGpio >= 0) {
_tftCsGpio = csGpio;
tft.cs = readPinHigh(csGpio); // 현재 상태 즉시 반영
addGpioListener(csGpio, (state) => {
const newCs = !!(state & 1);
if (!tft.cs && newCs) { tft.inRamWr = false; tft.pendingNeed = 0; tft.pendingArgs = []; }
tft.cs = newCs;
});
}
if (dcGpio >= 0) {
_tftDcGpio = dcGpio;
tft.dc = readPinHigh(dcGpio); // 현재 상태 즉시 반영
addGpioListener(dcGpio, (state) => { tft.dc = !!(state & 1); });
}
_rp2040TftListenersAdded = true;
tft.active = true;
_tftShowSection();
}
function oledWriteData(byte) {
oled.gddram[oled.page * 128 + oled.col] = byte;
oledDirty = true;
@@ -2003,6 +2130,7 @@ let _esp32SessionId = null;
let _esp32EvtSource = null;
let _esp32PinStates = {}; // {pin: 0/1}
let _esp32PinModes = {}; // {pin: 'O'/'I'}
let _esp32Fqbn = 'esp32:esp32:esp32';
let _esp32BootDone = false;
let _esp32SketchCode = ''; // current sketch code for re-compile
let _esp32SyncBusy = false;
@@ -2170,6 +2298,7 @@ let _esp8266SessionId = null;
let _esp8266EvtSource = null;
let _esp8266PinStates = {};
let _esp8266PinModes = {};
let _esp8266Fqbn = 'esp8266:esp8266:nodemcuv2';
let _esp8266BootDone = false;
let _esp8266SketchCode = '';
let _esp8266DhtPins = [];
@@ -2561,6 +2690,7 @@ function _updateEsp8266PinPopup() {
function initEsp8266Mode(sessionId, fname, fqbn) {
_IS_ESP8266 = true;
_esp8266SessionId = sessionId;
_esp8266Fqbn = fqbn || 'esp8266:esp8266:nodemcuv2';
_esp8266Variant = _esp8266VariantFromFqbn(fqbn);
if (_esp8266SketchCode && !_getEditorCode().trim()) _setEditorCode(_esp8266SketchCode);
document.querySelector('.header h1').textContent = '🖥️ ESP8266 Emulator';
@@ -2641,6 +2771,7 @@ function initEsp8266Mode(sessionId, fname, fqbn) {
inp.value = '';
};
_adcShowSection();
connectEsp8266Output();
}
@@ -2773,6 +2904,7 @@ function _esp8266SendDHTValues() {
function initEsp32Mode(sessionId, fname, fqbn) {
_IS_ESP32 = true;
_esp32SessionId = sessionId;
if (fqbn) _esp32Fqbn = fqbn;
if (_esp32SketchCode && !_getEditorCode().trim()) _setEditorCode(_esp32SketchCode);
// Update header
@@ -2850,6 +2982,7 @@ function initEsp32Mode(sessionId, fname, fqbn) {
_sensorReset();
document.getElementById('serial-input').disabled = false;
document.querySelector('.serial-send')?.removeAttribute('disabled');
_adcShowSection();
}
function _buildEsp32PinPopup() {
@@ -3134,6 +3267,7 @@ window.addEventListener('beforeunload', () => {
let cpu = null, uart = null, portB = null, portC = null, portD = null;
let rafId = null, ticksPerFrame = 160000, totalCycles = 0, running = false;
let hexData = null, emulatorReady = false;
let avrAdc = null, avrEeprom = null;
const PIN_COUNT_D = 14, PIN_COUNT_A = 6;
let serialLineSpan = null;
let _serialDecoder = null; // 스트리밍 TextDecoder — 멀티바이트 UTF-8 + Latin-1 안전 처리
@@ -3222,14 +3356,40 @@ function pinVoltage(port, pinIndex) {
function updatePinUI() {
if (!portD || !portB || !portC) return;
// PWM 핀 설정 (Arduino UNO): {pin: {TCCRA주소, OCR주소, COM비트시프트, 비트수}}
const _PWM = {
3: { tccrA: 0xB0, ocr: 0xB4, sh: 4, b: 8 }, // Timer2B → D3
5: { tccrA: 0x44, ocr: 0x48, sh: 4, b: 8 }, // Timer0B → D5
6: { tccrA: 0x44, ocr: 0x47, sh: 6, b: 8 }, // Timer0A → D6
9: { tccrA: 0x80, ocr: 0x88, sh: 6, b: 16 }, // Timer1A → D9
10: { tccrA: 0x80, ocr: 0x8A, sh: 4, b: 16 }, // Timer1B → D10
11: { tccrA: 0xB0, ocr: 0xB3, sh: 6, b: 8 }, // Timer2A → D11
};
function _pwmDuty(pin) {
const p = _PWM[pin]; if (!p || !cpu) return null;
const com = (cpu.data[p.tccrA] >> p.sh) & 0x3;
if (!com) return null; // COM=0 → 일반 GPIO (PWM 아님)
const ocr = p.b === 16
? (cpu.data[p.ocr] | (cpu.data[p.ocr + 1] << 8))
: cpu.data[p.ocr];
const max = p.b === 16 ? 65535 : 255;
return Math.round(ocr / max * 100);
}
// Digital pins D0-D7 (Port D) - 핀 헤더 자체가 LED
for (let i = 0; i < 8; i++) {
const val = pinVoltage(portD, i);
const led = document.getElementById('dpin-' + i);
const lbl = document.getElementById('dpinv-' + i);
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
// Board SVG 핀 LED
const duty = _pwmDuty(i);
if (duty !== null) {
if (led) led.className = 'pin-led pwm';
if (lbl) lbl.textContent = duty + '%';
} else {
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
}
document.getElementById('board-d' + i + '-led')?.setAttribute('fill', val ? '#22c55e' : '#0d2a0d');
}
// Digital pins D8-D13 (Port B)
@@ -3238,25 +3398,43 @@ function updatePinUI() {
const pinNum = 8 + i;
const led = document.getElementById('dpin-' + pinNum);
const lbl = document.getElementById('dpinv-' + pinNum);
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
// Board SVG 핀 LED
const duty = _pwmDuty(pinNum);
if (duty !== null) {
if (led) led.className = 'pin-led pwm';
if (lbl) lbl.textContent = duty + '%';
} else {
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
}
document.getElementById('board-d' + pinNum + '-led')?.setAttribute('fill', val ? '#22c55e' : '#0d2a0d');
// D13 built-in LED (온보드 LED)
if (i === 5) {
document.getElementById('board-led-13')?.setAttribute('fill', val ? '#22c55e' : '#1a1a1a');
}
}
// Analog pins A0-A5 (Port C)
// Analog pins A0-A5 (Port C) — ADC 값 표시
for (let i = 0; i < 6; i++) {
const val = pinVoltage(portC, i);
const led = document.getElementById('apin-' + i);
const lbl = document.getElementById('apinv-' + i);
// ADC 채널 값 읽기 (analogRead 중이 아닐 때는 설정된 channelValues 표시)
const adcV = avrAdc ? (avrAdc.channelValues[i] || 0) : 0;
if (led) led.className = 'pin-led' + (val ? ' in' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
// Board SVG 핀 LED
if (lbl) lbl.textContent = avrAdc ? adcV.toFixed(2) + 'V' : (val ? '1' : '0');
document.getElementById('board-a' + i + '-led')?.setAttribute('fill', val ? '#3b82f6' : '#0d2a0d');
}
// 회로도 pot/ldr → ADC 채널 동기화
if (avrAdc && _schLiveMap.analog && _schLiveMap.analog.size > 0) {
for (const [comp, ch] of _schLiveMap.analog) {
const v = ((comp.value != null ? comp.value : 0.5)) * 5.0;
if (Math.abs((avrAdc.channelValues[ch] || 0) - v) > 0.01) {
avrAdc.channelValues[ch] = v;
const slider = document.getElementById('adc-ch' + ch);
const lbl = document.getElementById('adc-ch' + ch + '-val');
if (slider) slider.value = Math.round(v * 100);
if (lbl) lbl.textContent = v.toFixed(2) + 'V';
}
}
}
// 회로도 LED 연동: GPIO 상태 변화 시 schDraw 요청
if (!_IS_ESP32 && !_IS_ESP8266 && _schLiveMap.led.size > 0) {
let needRedraw = false;
@@ -3284,6 +3462,23 @@ function initEmulator(hex) {
if (timer1Config) new AVRTimer(cpu, timer1Config);
if (timer2Config) new AVRTimer(cpu, timer2Config);
// ADC: analogRead() 지원 (슬라이더 값은 리셋 후에도 유지)
if (AVRADC && adcConfig) {
const prevValues = avrAdc ? [...avrAdc.channelValues] : null;
avrAdc = new AVRADC(cpu, adcConfig);
for (let _i = 0; _i < 6; _i++) {
const _s = document.getElementById('adc-ch' + _i);
avrAdc.channelValues[_i] = _s ? parseInt(_s.value) / 100
: (prevValues ? (prevValues[_i] || 0) : 0);
}
_adcShowSection();
}
// EEPROM: EEPROM.read()/write() 지원 (컴파일 시 초기화, 리셋 시 유지)
if (AVREEPROM && EEPROMMemoryBackend && eepromConfig) {
avrEeprom = new AVREEPROM(cpu, new EEPROMMemoryBackend(1024), eepromConfig);
}
setupTWI(cpu);
setupSPI(cpu);
@@ -3568,6 +3763,9 @@ function _rp2040Start() {
if (++checkN >= 500 && performance.now() >= deadline) break;
}
} catch(e) { _rp2040Running = false; appendSerial('\n[실행 오류: ' + e.message + ']\n', 'error'); return; }
// RAF 프레임마다 디스플레이 플러시 (TFT/OLED dirty flag 기반 — 변화 없으면 no-op)
tftFlushCanvas();
oledFlushCanvas();
// Heartbeat: 부팅 중에는 1초마다, 부팅 후에는 3초마다 PC/SP/LR/waiting 출력
if (_rp2040Sim && _rp2040Sim._booted) {
_rp2040RafId = requestAnimationFrame(tick);
@@ -3748,6 +3946,34 @@ async function initRP2040Mode(uf2B64, fname) {
});
}
// TFT/LCD/OLED 상태 초기화 (framebuf=null 상태에서 tftSPIByte 호출 시 crash 방지)
lcdReset(); oledReset(); tftReset();
// SPI0/SPI1 → TFT LCD 연결 (schematic에서 CS/DC GPIO 감지 후 리스너 추가됨)
_rp2040TftListenersAdded = false;
_setupRP2040SPI(sim);
// 스케치 코드 정적 분석으로 TFT CS/DC 핀 자동 감지 (회로도 없이도 동작)
_rp2040AutoDetectTft(sim);
// I2C0 / I2C1 → LCD + OLED 연결 (AVR setupTWI와 동일 로직)
for (const i2c of sim.rp2040.i2c) {
let _slave = null;
i2c.onStart = () => { i2c.completeStart(); };
i2c.onStop = () => { _slave = null; i2c.completeStop(); };
i2c.onConnect = (addr, write) => {
if (LCD_ADDRS.includes(addr)) { _slave = 'lcd'; i2c.completeConnect(true); return; }
if (OLED_ADDRS.includes(addr)) { _slave = 'oled'; oledOnI2CStart(); i2c.completeConnect(true); return; }
_slave = null; i2c.completeConnect(false);
};
i2c.onWriteByte = (byte) => {
if (_slave === 'lcd') lcdOnI2CByte(byte);
if (_slave === 'oled') oledOnI2CByte(byte);
i2c.completeWrite(true);
};
i2c.onReadByte = () => { i2c.completeRead(0xFF); };
}
document.getElementById('overlay').style.display = 'none';
const ledEl2 = document.getElementById('rp2040-led');
if (ledEl2) ledEl2.style.display = 'inline-block';
@@ -3780,14 +4006,13 @@ window.resetEmulator = () => {
}
_loadUF2(_rp2040Uf2Bytes, sim.rp2040);
// 벡터 테이블에서 SP/PC/VTOR 복원
// VTOR 설정 후 core.reset()으로 SP/PC 로드 (initRP2040Mode와 동일한 방식)
const fv2 = new DataView(sim.rp2040.flash.buffer);
const readEntry = (base) => ({ sp: fv2.getUint32(base, true), pc: fv2.getUint32(base + 4, true) & ~1, base });
const cands = [readEntry(0x3000), readEntry(0x100)].filter(v => v.sp > 0x20000000 && v.pc >= 0x10000000);
const readEntry = (base) => ({ sp: fv2.getUint32(base, true), pc: fv2.getUint32(base + 4, true), base });
const cands = [readEntry(0x3000), readEntry(0x100)].filter(v => v.sp > 0x20000000 && (v.pc & ~1) >= 0x10000000);
const entry = cands[0] || readEntry(0x3000);
sim.rp2040.core.VTOR = 0x10000000 + entry.base;
sim.rp2040.core.SP = entry.sp;
sim.rp2040.core.PC = entry.pc;
sim.rp2040.core.reset();
// rp2040js 로거 억제 (info/debug/warn 등 콘솔 범람 방지)
{
@@ -6584,6 +6809,101 @@ function _schGpioFromNet_direct(netKey, find, pk) {
return -1;
}
// ── ADC (analogRead) 슬라이더 UI ─────────────────────────────────────────────
function _adcShowSection() {
const sec = document.getElementById('adc-section');
if (!sec) return;
sec.style.display = '';
const dot = document.getElementById('adc-dot');
if (dot) dot.style.background = '#8b5cf6';
_adcBuildControls();
}
// ESP32 A0-A5 GPIO 핀 번호
const _ESP32_ADC_PINS = [36, 39, 34, 35, 32, 33];
function _adcBuildControls() {
const wrap = document.getElementById('adc-controls');
if (!wrap) return;
wrap.innerHTML = ''; // 보드 전환 시 재빌드
const maxSlider = _IS_ESP32 || _IS_ESP8266 ? 330 : 500; // 3.3V vs 5V
const maxLabel = _IS_ESP32 || _IS_ESP8266 ? '3.3' : '5.0';
const count = _IS_ESP8266 ? 1 : 6;
for (let i = 0; i < count; i++) {
const row = document.createElement('div');
row.className = 'dht-row';
const pinLabel = _IS_ESP32
? `A${i}<small style="color:#6b7280;margin-left:2px">GPIO${_ESP32_ADC_PINS[i]}</small>`
: `A${i}`;
row.innerHTML =
`<span class="dht-param">${pinLabel}</span>` +
`<input type="range" id="adc-ch${i}" min="0" max="${maxSlider}" value="0" oninput="window.updateADCValue(${i})">` +
`<span class="dht-val" id="adc-ch${i}-val">0V</span>`;
wrap.appendChild(row);
}
const hint = document.createElement('div');
hint.style.cssText = 'font-size:10px;color:#6b7280;padding:2px 4px;';
hint.textContent = `0 ~ ${maxLabel}V`;
wrap.appendChild(hint);
}
window.updateADCValue = function(ch) {
const slider = document.getElementById('adc-ch' + ch);
if (!slider) return;
const v = parseInt(slider.value) / 100;
const disp = v.toFixed(2) + 'V';
document.getElementById('adc-ch' + ch + '-val').textContent = disp;
if (_IS_ESP32 && _esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/adc', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: _ESP32_ADC_PINS[ch], voltage: v }),
}).catch(() => {});
} else if (_IS_ESP8266 && _esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/adc', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voltage: v }),
}).catch(() => {});
} else if (avrAdc) {
avrAdc.channelValues[ch] = v;
}
};
function _adcSyncSliders() {
if (!avrAdc) return;
_adcShowSection();
for (let i = 0; i < 6; i++) {
const slider = document.getElementById('adc-ch' + i);
const val = document.getElementById('adc-ch' + i + '-val');
if (slider) slider.value = Math.round((avrAdc.channelValues[i] || 0) * 100);
if (val) val.textContent = ((avrAdc.channelValues[i] || 0)).toFixed(2) + 'V';
}
}
// 회로도 pot/ldr → ADC 채널 연결 (A0~A5에 연결된 pot/ldr을 _schLiveMap.analog에 기록)
function _buildAdcSchMap() {
if (!_schLiveMap.analog) _schLiveMap.analog = new Map();
_schLiveMap.analog.clear();
if (!SCH.components.length) return;
const { find, pk } = _schBuildNets();
for (const comp of SCH.components) {
if (comp.type !== 'pot' && comp.type !== 'ldr' && comp.type !== 'ntc') continue;
const terms = schTermPositions(comp);
// pot: terms[0]=wiper, terms[1]=left, terms[2]=right
// ldr/ntc: terms[0], terms[1]
const wiperTerm = terms[0];
if (!wiperTerm) continue;
const net = find(pk(wiperTerm.x, wiperTerm.y));
const gpio = _schGpioFromNet(net, find, pk);
// analogRead는 A0=gpio14, A1=15, ..., A5=19
if (gpio >= 14 && gpio <= 19) {
_schLiveMap.analog.set(comp, gpio - 14); // ch 0-5
}
}
}
function _buildSchLiveMap() {
_schLiveMap.led.clear();
_schLiveMap.btn.clear();
@@ -6607,6 +6927,7 @@ function _buildSchLiveMap() {
if (gpio >= 0) _schLiveMap.btn.set(comp, gpio);
}
}
_buildAdcSchMap();
}
// ── AVR TFT 회로도 감지: CS/DC GPIO 추출 → SPI 핀 동적 업데이트 ─────────────────
@@ -6645,6 +6966,7 @@ function schUpdateNetlist() {
if (ta) ta.value=schToNetlist();
if (_IS_ESP32) { setupDHTSensorsEsp32(); setupTFTSectionEsp32(); _buildSchLiveMap(); }
else if (_IS_ESP8266) { setupDHTSensorsEsp8266(); setupTFTSectionEsp32(); _buildSchLiveMap(); }
else if (_IS_RP2040) { _setupTFTFromSchematicRP2040(); _buildSchLiveMap(); }
else {
_setupTFTFromSchematicAVR();
if (emulatorReady) { setupDHTSensors(); _buildSchLiveMap(); }
@@ -7123,7 +7445,7 @@ window.compileAndRun = async function compileAndRun() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, fqbn: 'esp32:esp32:esp32' }),
body: JSON.stringify({ code, fqbn: _esp32Fqbn }),
});
const data = await r.json();
if (data.error) {
@@ -7139,7 +7461,7 @@ window.compileAndRun = async function compileAndRun() {
statusEl.textContent = '✅ ESP32 실행 중…';
}
statusEl.style.color = 'var(--green)';
initEsp32Mode(data.id, document.getElementById('code-editor-filename').textContent, 'esp32:esp32:esp32');
initEsp32Mode(data.id, document.getElementById('code-editor-filename').textContent, _esp32Fqbn);
document.getElementById('filename').textContent = document.getElementById('code-editor-filename').textContent;
} catch (e) {
statusEl.textContent = '❌ 서버 오류: ' + e.message;
@@ -7151,13 +7473,51 @@ window.compileAndRun = async function compileAndRun() {
return;
}
// UNO mode
// ESP8266 mode
if (_IS_ESP8266) {
try {
if (_esp8266SessionId) {
try { await fetch('/api/arduino/esp8266/' + _esp8266SessionId, { method:'DELETE', credentials:'include' }); } catch {}
if (_esp8266EvtSource) { _esp8266EvtSource.close(); _esp8266EvtSource = null; }
_esp8266SessionId = null;
}
const r = await fetch('/api/arduino/esp8266/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, fqbn: _esp8266Fqbn }),
});
const data = await r.json();
if (data.error) {
statusEl.textContent = '❌ 컴파일 오류';
statusEl.style.color = 'var(--red)';
const errLines = data.error.split('\n').filter(l => l.includes('error:') || l.includes(':')).slice(0, 5);
showOverlayError('컴파일 오류\n\n' + errLines.join('\n'));
return;
}
statusEl.textContent = '✅ ESP8266 실행 중…';
statusEl.style.color = 'var(--green)';
_esp8266SketchCode = code;
initEsp8266Mode(data.id, document.getElementById('code-editor-filename').textContent, _esp8266Fqbn);
document.getElementById('filename').textContent = document.getElementById('code-editor-filename').textContent;
} catch (e) {
statusEl.textContent = '❌ 서버 오류: ' + e.message;
statusEl.style.color = 'var(--red)';
} finally {
btn.disabled = false;
btn.textContent = '▶ 컴파일';
}
return;
}
// AVR (UNO/Nano/Mega) mode
try {
const _boardStr = _avrBoardType === 'board_nano' ? 'nano' : _avrBoardType === 'board_mega' ? 'mega' : 'uno';
const r = await fetch('/api/arduino/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, board: 'uno' }),
body: JSON.stringify({ code, board: _boardStr }),
});
const data = await r.json();
if (data.error) {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+79 -89
View File
@@ -59,6 +59,20 @@
<button class="mode-btn" id="btn-schedule" onclick="setMode('schedule')">일정</button>
</div>
<div class="mode-toggle" style="margin-left:8px">
<button class="mode-btn" onclick="setSidebarTab('skills')" title="스킬 관리">🧩 스킬</button>
<button class="mode-btn" onclick="window.open('/code.html','_blank')" title="코드 에디터">💻 코드</button>
<button class="mode-btn" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작">📊 슬라이드</button>
<button class="mode-btn" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button>
<button class="mode-btn" onclick="window.open('/music-app.html','_blank')" title="음악 스튜디오">🎵 음악</button>
<button class="mode-btn" onclick="window.open('/accountant-app.html','_blank')" title="회계 어시스턴트">📊 회계</button>
<button class="mode-btn" onclick="window.open('/mind-app.html','_blank')" title="마음 상담실">🧠 상담</button>
<button class="mode-btn" onclick="window.open('/lawyer-app.html','_blank')" title="법률 어시스턴트">⚖️ 법률</button>
<button class="mode-btn" onclick="window.open('/investor-app.html','_blank')" title="투자 어시스턴트">📈 투자</button>
<button class="mode-btn" onclick="window.open('/weather-app.html','_blank')" title="날씨 어시스턴트">🌤️ 날씨</button>
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
</div>
<div class="status-bar">
<button
id="theme-toggle"
@@ -93,12 +107,6 @@
<div class="dot" id="ollama-dot"></div>
<span id="ollama-status">확인 중...</span>
</div>
<div class="status-pill" id="model-pill" onclick="openSettings('models')" title="모델 변경하려면 클릭" style="cursor:pointer">
<span id="current-provider-badge" style="font-size:10px;font-weight:700;color:var(--muted);margin-right:4px"></span><span id="current-model">-</span>
</div>
<div class="status-pill stat-compact" id="system-pill">
<span id="quick-system-stats">CPU --% • RAM --% • GPU --%</span>
</div>
</div>
</header>
@@ -107,23 +115,55 @@
<button id="mob-menu-close" onclick="mobTab('chat')" style="display:none;position:absolute;top:12px;right:12px;z-index:10;border:1px solid var(--line);background:var(--panel-2);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:700;color:var(--muted);cursor:pointer;font-family:'Manrope',sans-serif">✕ 닫기</button>
<div id="sidebar-tabs">
<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="window.open('/code.html','_blank')" title="코드 에디터 (새 탭)">💻 코드</button>
<button class="sidebar-tab" id="tab-pptx" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작 마법사">📊 슬라이드</button>
<button class="sidebar-tab" id="tab-language" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button>
</div>
<!-- SESSIONS TAB -->
<div id="sidebar-jobs">
<div class="sidebar-section" style="border-top:none">
<div id="jobs-list" style="flex:1;min-height:0;overflow-y:auto">
<div class="empty-state">아직 대화가 없습니다.<br>새 대화를 시작해 보세요.</div>
</div>
<!-- 새 채팅 + 편집 버튼 (하단) -->
<div class="sidebar-section" style="flex-shrink:0;border-top:1px solid var(--line);border-bottom:none">
<span></span>
<div style="display:flex;align-items:center;gap:8px">
<button class="sidebar-action" onclick="newChatSession()">+ 새 채팅</button>
<button class="sidebar-action" id="sessions-edit-btn" onclick="toggleSessionsEditMode()">편집</button>
</div>
</div>
<div id="jobs-list">
<div class="empty-state">아직 대화가 없습니다.<br>새 대화를 시작해 보세요.</div>
<!-- 모델명 (한 줄) -->
<div id="model-pill" onclick="openSettings('models')" title="모델 변경하려면 클릭"
style="cursor:pointer;flex-shrink:0;display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid var(--line);background:var(--panel-2);font-size:13px;font-weight:800;color:var(--text)">
<span style="font-size:11px;color:var(--muted);font-weight:700">모델</span>
<span id="current-provider-badge" style="font-size:11px;font-weight:800;color:var(--muted)"></span>
<span id="current-model" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">-</span>
</div>
<!-- 리소스 바 -->
<div id="system-pill" style="flex-shrink:0;display:flex;flex-direction:column;gap:5px;padding:9px 14px;border-top:1px solid var(--line);background:var(--panel-2)">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">CPU</span>
<div class="sys-meter-track"><i id="sb-sys-cpu-meter"></i></div>
<span id="sb-sys-cpu" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">RAM</span>
<div class="sys-meter-track"><i id="sb-sys-ram-meter"></i></div>
<span id="sb-sys-ram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU</span>
<div class="sys-meter-track"><i id="sb-sys-vram-meter"></i></div>
<span id="sb-sys-vram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">↓</span>
<div class="sys-meter-track"><i id="sb-sys-rx-meter" style="background:linear-gradient(90deg,#059669,#10b981)"></i></div>
<span id="sb-sys-rx" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:52px;text-align:right;flex-shrink:0">0 B/s</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">↑</span>
<div class="sys-meter-track"><i id="sb-sys-tx-meter" style="background:linear-gradient(90deg,#d97706,#f59e0b)"></i></div>
<span id="sb-sys-tx" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:52px;text-align:right;flex-shrink:0">0 B/s</span>
</div>
</div>
</div>
@@ -131,7 +171,10 @@
<div id="sidebar-skills">
<div class="sidebar-section" style="border-top:none">
<span>Skills</span>
<button class="sidebar-action" onclick="openCreateSkillModal()">+ Create</button>
<div style="display:flex;gap:6px">
<button class="sidebar-action" onclick="openCreateSkillModal()">+ Create</button>
<button class="sidebar-action" onclick="setSidebarTab('jobs')" title="세션 패널로 돌아가기">✕</button>
</div>
</div>
<div id="skills-user-selector" style="display:none;padding:5px 10px 6px;border-bottom:1px solid var(--line);background:var(--panel-2)">
<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:3px">대상 유저</label>
@@ -233,6 +276,14 @@
<button class="log-filter-btn log-filter-all active" onclick="setAllLogFilters(true)" style="margin-left:auto">전체</button>
<button class="log-filter-btn log-filter-none" onclick="setAllLogFilters(false)">없음</button>
</div>
<div id="sidebar-log-search" onclick="event.stopPropagation()" style="display:flex;align-items:center;gap:4px;padding:3px 8px;border-bottom:1px solid var(--line);flex-shrink:0">
<span style="font-size:10px;color:var(--muted);flex-shrink:0">🔍</span>
<input id="log-search-input" type="text" placeholder="검색..." oninput="onLogSearch(this.value)" onkeydown="if(event.key==='Escape'){this.value='';onLogSearch('');}"
style="flex:1;background:none;border:none;outline:none;font-size:11px;font-family:'IBM Plex Mono',monospace;color:var(--text);min-width:0">
<span id="log-search-count" style="font-size:10px;color:var(--muted);flex-shrink:0;display:none"></span>
<button id="log-search-clear" onclick="document.getElementById('log-search-input').value='';onLogSearch('');" title="지우기"
style="display:none;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:0 2px">✕</button>
</div>
<div id="sidebar-log-body"></div>
</div>
</aside>
@@ -519,83 +570,39 @@
</button>
</div>
<div class="right-section" id="system-stats-section">
<div class="right-section-title">시스템 통계</div>
<div class="sys-grid">
<div class="sys-card">
<div class="sys-k">CPU</div>
<div class="sys-v" id="sys-cpu">--%</div>
<div class="sys-meter"><i id="sys-cpu-meter"></i></div>
</div>
<div class="sys-card">
<div class="sys-k">RAM</div>
<div class="sys-v" id="sys-ram">--%</div>
<div class="sys-meter"><i id="sys-ram-meter"></i></div>
</div>
<div class="sys-card">
<div class="sys-k">GPU</div>
<div class="sys-v" id="sys-gpu">--%</div>
<div class="sys-meter"><i id="sys-gpu-meter"></i></div>
</div>
<div class="sys-card">
<div class="sys-k">VRAM</div>
<div class="sys-v" id="sys-vram">--%</div>
<div class="sys-meter"><i id="sys-vram-meter"></i></div>
</div>
<div class="sys-card full" onclick="openSettings('models')" title="제공자/모델 변경하려면 클릭" style="cursor:pointer">
<div class="sys-k">활성 LLM</div>
<div class="sys-v" id="sys-ollama">확인 중...</div>
<div class="sys-sub" id="sys-runtime-sub">Gateway RSS -- MB • Updated --</div>
</div>
<div class="sys-card full" onclick="openSettings('context-viewer')" title="컨텍스트 뷰어 열기" style="cursor:pointer">
<div class="sys-k">접속자</div>
<div class="sys-v" id="sys-online-users" style="font-size:12px;font-weight:600;letter-spacing:0">-</div>
</div>
<div class="right-section" style="padding:6px 10px">
<div class="sys-card full" onclick="openSettings('context-viewer')" title="컨텍스트 뷰어 열기" style="cursor:pointer">
<div class="sys-k">접속자</div>
<div class="sys-v" id="sys-online-users" style="font-size:12px;font-weight:600;letter-spacing:0">-</div>
</div>
</div>
<div class="right-section" id="agent-context-section">
<div class="right-section-title">에이전트 컨텍스트</div>
<div class="agent-card">
<div class="agent-metric-grid">
<div class="agent-metric-grid" style="grid-template-columns:repeat(4,1fr)">
<div class="agent-metric">
<div class="k">세션 모드</div>
<div class="k">모드</div>
<div class="v" id="r-session-mode">-</div>
</div>
<div class="agent-metric">
<div class="k">에이전트 상태</div>
<div class="k">상태</div>
<div class="v" id="r-agent-state">-</div>
</div>
</div>
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">개요</div>
<div class="v" id="r-overview-objective">-</div>
</div>
<div class="agent-metric">
<div class="k">Active</div>
<div class="v" id="r-active-objective">-</div>
</div>
</div>
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">작업</div>
<div class="v" id="r-task-counts">0</div>
</div>
<div class="agent-metric">
<div class="k">Turns</div>
<div class="k">턴</div>
<div class="v" id="r-turn-counts">0</div>
</div>
</div>
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">하트비트</div>
<div class="v" id="r-heartbeat-state">idle</div>
</div>
<div class="agent-metric">
<div class="k">감시</div>
<div class="v" id="r-heartbeat-meta">step:-</div>
</div>
</div>
<!-- hidden placeholders so JS still writes to them without errors -->
<span id="r-overview-objective" style="display:none"></span>
<span id="r-active-objective" style="display:none"></span>
<span id="r-heartbeat-state" style="display:none"></span>
<span id="r-heartbeat-meta" style="display:none"></span>
<div class="task-progress-wrap"><div class="task-progress-fill" id="r-task-progress-bar"></div></div>
<div class="agent-context-scroll" id="r-agent-context-scroll">
<div class="task-list" id="r-turn-execution">
@@ -758,7 +765,6 @@
<button id="settings-tab-models" class="sidebar-action" onclick="setSettingsTab('models')">모델</button>
<button id="settings-tab-agents" class="sidebar-action" onclick="setSettingsTab('agents')">에이전트</button>
<button id="settings-tab-channels" class="sidebar-action" onclick="setSettingsTab('channels')">채널</button>
<button id="settings-tab-ppt" class="sidebar-action" onclick="setSettingsTab('ppt')">프레젠테이션</button>
<button id="settings-tab-voice" class="sidebar-action" onclick="setSettingsTab('voice')">음성</button>
<button id="settings-tab-session" class="sidebar-action" onclick="setSettingsTab('session')">세션</button>
<button id="settings-tab-integrations" class="sidebar-action" onclick="setSettingsTab('integrations')">연동</button>
@@ -1828,22 +1834,6 @@
</div>
</div>
<!-- PPT Template & Skin Selection -->
<div id="settings-panel-ppt" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">템플릿</div>
<div id="ppt-templates-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:8px"></div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">스킨 (배경)</div>
<div id="ppt-skins-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px"></div>
</div>
</div>
<div style="margin-top:12px;padding:8px 10px;background:#f0f6ff;border:1px solid #bdd3f6;border-radius:8px;font-size:11px;color:#1e40af">
선택한 템플릿과 스킨은 프레젠테이션 제작 시 기본값으로 적용됩니다. 채팅에서 <code>template: "dark"</code>, <code>background: "ocean"</code> 등으로 개별 지정할 수도 있습니다.
</div>
</div>
<div id="settings-panel-context-viewer" style="display:none;padding:14px 16px">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30 -3
View File
@@ -210,6 +210,9 @@
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
#sidebar-tabs:has(button:only-child) {
display: none;
}
.sidebar-tab {
flex: 1;
@@ -239,8 +242,8 @@
/* ── Server log panel ─────────────────────────────── */
#sidebar-log-panel {
flex: 0 0 50%;
max-height: 50%;
flex: 0 0 33%;
max-height: 33%;
min-height: 0;
display: flex;
flex-direction: column;
@@ -252,7 +255,8 @@
min-height: 0;
}
#sidebar-log-panel.collapsed #sidebar-log-body,
#sidebar-log-panel.collapsed #sidebar-log-filters {
#sidebar-log-panel.collapsed #sidebar-log-filters,
#sidebar-log-panel.collapsed #sidebar-log-search {
display: none;
}
#log-collapse-btn {
@@ -332,6 +336,8 @@
#sidebar-log-body.filter-active.hide-warn .log-line.lv-warn { display: none; }
#sidebar-log-body.filter-active.hide-error .log-line.lv-error { display: none; }
#log-search-input::placeholder { color: var(--muted); opacity: 0.6; }
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
@@ -1598,6 +1604,22 @@
background: linear-gradient(90deg, #1e9d7b 0%, #1b75e6 55%, #0f4fba 100%);
transition: width 0.35s ease;
}
.sys-meter-track {
flex: 1;
height: 8px;
border-radius: 999px;
background: var(--line);
overflow: hidden;
}
.sys-meter-track > i {
display: block;
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, #1e9d7b 0%, #1b75e6 55%, #0f4fba 100%);
transition: width 0.35s ease;
}
:root[data-theme="dark"] .sys-meter-track { background: #1e2535; }
#r-live-steps,
#r-recent-turns {
max-height: none !important;
@@ -3025,3 +3047,8 @@
/* ── Block flip-flop alternating highlight ───────────────────────────── */
.ff-block-even { background-color: rgba(59,130,246,0.18) !important; }
.ff-block-odd { background-color: rgba(251,146,60,0.18) !important; }
/* ── Log search ──────────────────────────────────────────────────────── */
.log-line.no-match { display: none !important; }
.log-hl { background: #fde047; color: #1a1a1a; border-radius: 2px; padding: 0 1px; }
:root[data-theme="dark"] .log-hl { background: #854d0e; color: #fef3c7; }
+2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long