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:
+128
-1
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user