v3.0.7: 우즈베크어 학습 앱 + 서버로그 필터 + 세션 삭제 동기화

- 우즈베크어 언어 교육 스킬(SKILL.md) 및 language-app.html 추가
- /api/language/* 백엔드 라우트 (레슨·플래시카드·퀴즈·TTS·진행도)
- 메인 페이지에 언어 앱 버튼 추가
- 서버 로그 레벨 필터(INFO/WARN/ERROR) — 선택 레벨만 표시, 빈 결과 안내
- 과거 세션 서버↔클라이언트 동기화 (/api/chat/sessions GET)
- 세션 삭제 시 서버 파일도 함께 삭제 (DELETE /api/chat/sessions/:id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-20 11:44:59 +09:00
co-authored by Claude Sonnet 4.6
parent 209f3a3aef
commit 0dae10693f
9 changed files with 1544 additions and 6 deletions
+128 -1
View File
@@ -42,6 +42,69 @@ initMicAutosendToggle();
// ── Server log panel ──────────────────────────────────────────────────────────
let logScrollLocked = true;
let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size
// 빈 Set = 전체 표시 / 값 있으면 해당 레벨만 표시
const logLevelFilter = new Set();
const logLevelCounts = { log: 0, warn: 0, error: 0 };
function toggleLogFilter(level) {
if (logLevelFilter.has(level)) {
logLevelFilter.delete(level);
} else {
logLevelFilter.add(level);
}
applyLogFilters();
}
function setAllLogFilters(on) {
logLevelFilter.clear();
if (!on) logLevelFilter.add('__none__');
applyLogFilters();
}
function applyLogFilters() {
const body = document.getElementById('sidebar-log-body');
if (body) {
const none = logLevelFilter.has('__none__');
const filtered = !none && logLevelFilter.size > 0;
body.classList.toggle('filter-active', filtered || none);
['log', 'warn', 'error'].forEach(lv => {
body.classList.toggle('hide-' + lv, none || (filtered && !logLevelFilter.has(lv)));
});
// 필터 결과 빈 경우 안내 메시지
let emptyEl = body.querySelector('.log-empty-hint');
const none2 = logLevelFilter.has('__none__');
const filtered2 = !none2 && logLevelFilter.size > 0;
const visibleCount = none2 ? 0 : filtered2
? [...logLevelFilter].reduce((s, lv) => s + (logLevelCounts[lv] || 0), 0)
: logLevelCounts.log + logLevelCounts.warn + logLevelCounts.error;
if (visibleCount === 0 && (none2 || filtered2)) {
if (!emptyEl) {
emptyEl = document.createElement('div');
emptyEl.className = 'log-empty-hint';
emptyEl.style.cssText = 'padding:12px;text-align:center;color:var(--muted);font-size:11px;';
body.appendChild(emptyEl);
}
emptyEl.textContent = none2 ? '필터: 없음' : `해당 레벨 메시지 없음 (${[...logLevelFilter].map(l=>l.toUpperCase()).join(', ')})`;
} else if (emptyEl) {
emptyEl.remove();
}
}
// 버튼: 활성 상태 + 카운트
document.querySelectorAll('.log-filter-btn[data-level]').forEach(btn => {
const lv = btn.dataset.level;
const none = logLevelFilter.has('__none__');
const filtered = !none && logLevelFilter.size > 0;
btn.classList.toggle('active', !none && (!filtered || logLevelFilter.has(lv)));
const cnt = logLevelCounts[lv] || 0;
btn.title = `${lv.toUpperCase()}: ${cnt}건`;
});
const allBtn = document.querySelector('.log-filter-all');
const noneBtn = document.querySelector('.log-filter-none');
if (allBtn) allBtn.classList.toggle('active', logLevelFilter.size === 0);
if (noneBtn) noneBtn.classList.toggle('active', logLevelFilter.has('__none__'));
}
function toggleLogPanel() {
const panel = document.getElementById('sidebar-log-panel');
@@ -60,6 +123,8 @@ function clearLogPanel() {
const body = document.getElementById('sidebar-log-body');
if (body) body.innerHTML = '';
logLastTotalCount = 0;
logLevelCounts.log = 0; logLevelCounts.warn = 0; logLevelCounts.error = 0;
applyLogFilters();
}
function renderLogEntries(entries) {
@@ -67,8 +132,11 @@ function renderLogEntries(entries) {
if (!body) return;
const frag = document.createDocumentFragment();
for (const e of entries) {
const lv = e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : 'log';
logLevelCounts[lv] = (logLevelCounts[lv] || 0) + 1;
const lvCls = lv === 'error' ? 'error lv-error' : lv === 'warn' ? 'warn lv-warn' : 'lv-log';
const line = document.createElement('div');
line.className = 'log-line ' + (e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : '');
line.className = 'log-line ' + lvCls;
const ts = document.createElement('span');
ts.className = 'log-ts';
ts.textContent = e.ts;
@@ -81,6 +149,7 @@ function renderLogEntries(entries) {
}
body.appendChild(frag);
if (logScrollLocked) body.scrollTop = body.scrollHeight;
applyLogFilters();
}
async function pollServerLogs() {
@@ -256,6 +325,43 @@ function loadChatSessions() {
}
if (!agentSessionId || agentSessionId !== activeChatSessionId) setAgentSessionId(activeChatSessionId);
syncActiveChat();
// 서버 세션 목록과 비교해 누락된 세션을 사이드바에 추가
syncServerSessions();
}
async function syncServerSessions() {
try {
const res = await fetch('/api/chat/sessions');
if (!res.ok) return;
const data = await res.json();
if (!data.success || !Array.isArray(data.sessions)) return;
let changed = false;
for (const srv of data.sessions) {
const existing = chatSessions.find(s => s.id === srv.id);
if (!existing) {
// 서버에만 있는 세션 → 스텁으로 추가 (history는 열 때 로드)
chatSessions.push({
id: srv.id,
title: srv.title || 'New chat',
history: [],
processLog: [],
createdAt: srv.createdAt,
updatedAt: srv.lastActiveAt,
serverOnly: true, // 열 때 서버에서 history 로드
});
changed = true;
} else if (existing.serverOnly) {
// 이미 스텁으로 있으면 메타만 업데이트
existing.title = srv.title || existing.title;
existing.updatedAt = srv.lastActiveAt;
changed = true;
}
}
if (changed) {
saveChatSessions();
renderSessionsList();
}
} catch { /* 서버 미응답 시 무시 */ }
}
function syncActiveChat() {
@@ -318,11 +424,32 @@ function deleteChatSession(id, ev) {
saveChatSessions();
syncActiveChat();
// 서버 세션 파일도 삭제 (재로드 시 재등장 방지)
api(`/api/chat/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
function openSession(id) {
activeChatSessionId = id;
setAgentSessionId(id);
const sess = chatSessions.find(s => s.id === id);
if (sess && sess.serverOnly && sess.history.length === 0) {
// 서버에서 history 로드 후 렌더
fetch(`/api/chat/sessions/${id}`)
.then(r => r.json())
.then(data => {
if (data.success && Array.isArray(data.history)) {
sess.history = data.history;
sess.title = makeSessionTitle(data.history) || sess.title;
delete sess.serverOnly;
saveChatSessions();
syncActiveChat();
renderSessionsList();
}
})
.catch(() => syncActiveChat());
renderSessionsList(); // 즉시 세션 활성화 표시
return;
}
syncActiveChat();
}
+9 -1
View File
@@ -23,7 +23,7 @@
<script src="/vendor/addon-web-links.js"></script>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="/vendor/tone-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="/vendor/vs/loader.js"></script>
<script>
@@ -110,6 +110,7 @@
<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 -->
@@ -225,6 +226,13 @@
<button id="log-collapse-btn" onclick="toggleLogPanel()" title="접기/펼치기" style="font-size:13px">▾</button>
</div>
</div>
<div id="sidebar-log-filters" onclick="event.stopPropagation()" style="display:flex;gap:3px;padding:4px 8px;border-bottom:1px solid var(--line);flex-shrink:0;flex-wrap:wrap">
<button class="log-filter-btn active" data-level="log" onclick="toggleLogFilter('log')">INFO</button>
<button class="log-filter-btn active" data-level="warn" onclick="toggleLogFilter('warn')">WARN</button>
<button class="log-filter-btn active" data-level="error" onclick="toggleLogFilter('error')">ERROR</button>
<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-body"></div>
</div>
</aside>
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -251,7 +251,8 @@
flex: 0 0 auto;
min-height: 0;
}
#sidebar-log-panel.collapsed #sidebar-log-body {
#sidebar-log-panel.collapsed #sidebar-log-body,
#sidebar-log-panel.collapsed #sidebar-log-filters {
display: none;
}
#log-collapse-btn {
@@ -313,6 +314,24 @@
:root[data-theme="dark"] .log-line.warn .log-msg { color: #fbbf24; }
:root[data-theme="dark"] .log-line.error .log-msg { color: #f87171; }
/* 로그 레벨 필터 버튼 */
.log-filter-btn {
background: none; border: 1px solid var(--line); border-radius: 4px;
padding: 1px 6px; font-size: 10px; font-weight: 700; color: var(--muted);
cursor: pointer; font-family: 'IBM Plex Mono', monospace; transition: .12s;
letter-spacing: .03em;
}
.log-filter-btn:hover { border-color: var(--brand); color: var(--brand); }
.log-filter-btn[data-level="log"].active { border-color: #60a5fa; color: #60a5fa; background: rgba(96,165,250,.1); }
.log-filter-btn[data-level="warn"].active { border-color: #fbbf24; color: #fbbf24; background: rgba(251,191,36,.1); }
.log-filter-btn[data-level="error"].active { border-color: #f87171; color: #f87171; background: rgba(248,113,113,.1); }
.log-filter-all, .log-filter-none { font-weight: 400; letter-spacing: 0; }
/* 필터 숨김: filter-active일 때 hide-* 클래스로 해당 레벨 숨김 */
#sidebar-log-body.filter-active.hide-log .log-line.lv-log { display: none; }
#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; }
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--line);