v4.1.13: 언어앱 타일뷰 기본화 + 카테고리 추가 + 선생님 Q&A 패널

기본 보기를 타일 그리드로 전환하고 사용자가 카테고리를 직접 추가할 수 있게 함.
문법/단어/문화 질문에 답하는 "선생님" 채팅 패널 신설(/api/language/ask-teacher).
선생님 답변의 마크다운(표·볼드·목록·인용)이 그대로 노출되던 버그를 자체 렌더러로 수정.
This commit is contained in:
kim
2026-07-16 15:30:48 +09:00
parent edd7816048
commit f6f7f14541
3 changed files with 243 additions and 23 deletions
+2 -1
View File
@@ -210,7 +210,8 @@
"Bash(/home/kim/homeclaw/.smallclaw/voice-venv/bin/pip list *)",
"Bash(apt-cache policy *)",
"Bash(find / -maxdepth 4 -iname \"config.json\" -path \"*smallclaw*\")",
"Read(//root/**)"
"Read(//root/**)",
"Bash(echo \"done, exit=$?\")"
]
},
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
+41
View File
@@ -171,6 +171,47 @@ JSON으로만 응답하세요 (코드 블록 없이):
}
});
// POST /api/language/ask-teacher — general Q&A chat, NOT translation (that's /interpret).
// A student asks about grammar/usage/culture/vocabulary in Korean or Uzbek and gets an
// explanation + examples, with short conversation history for follow-up questions.
app.post('/api/language/ask-teacher', async (req: express.Request, res: express.Response) => {
try {
const message = String(req.body?.message || '').trim().slice(0, 1000);
if (!message) { res.status(400).json({ success: false, error: 'message required' }); return; }
const historyRaw = Array.isArray(req.body?.history) ? req.body.history : [];
// Cap history to the last 10 turns — this is a lightweight tutor chat, not a full
// session-backed conversation; the client resends what it wants remembered each time.
const history = historyRaw.slice(-10).filter((m: any) => m?.role && m?.content).map((m: any) => ({
role: m.role === 'assistant' ? 'assistant' as const : 'user' as const,
content: String(m.content).slice(0, 1000),
}));
const systemPrompt = `당신은 친절한 우즈베크어 선생님입니다. 한국인 학습자의 질문에 답합니다.
- 문법, 단어 용법, 발음, 우즈베크 문화·관습에 대한 질문에 답하세요.
- 설명은 한국어로 하고, 우즈베크어 예문은 항상 라틴 문자(1993년 이후 표준, oʻ/gʻ 포함)로 정확히 표기하세요.
- 예문에는 한글 발음 표기와 뜻을 함께 달아주세요.
- 학습자가 문장을 만들어서 봐달라고 하면 자연스러운 표현으로 고쳐주고 왜 그런지 짧게 설명하세요.
- 통역 요청(문장을 그냥 번역해달라는 요청)이 오면 번역은 해주되, 배울 점(문법 포인트 등)을 한 줄 덧붙이세요.
- 답변은 간결하게 — 장황한 설명보다 핵심 위주로, 이모지는 최소화.`;
const ollama = getOllamaClient();
const result = await ollama.chatWithThinking(
[
{ role: 'system' as const, content: systemPrompt },
...history,
{ role: 'user' as const, content: message },
],
'executor',
{ num_predict: 800, temperature: 0.4, think: false }
);
const reply = String(result.message?.content || '').trim();
if (!reply) { res.json({ success: false, error: '응답 생성 실패' }); return; }
res.json({ success: true, reply });
} catch (err: any) {
res.status(500).json({ success: false, error: String(err?.message || err) });
}
});
// POST /api/language/tts — Uzbek TTS via edge-tts
app.post('/api/language/tts', async (req: express.Request, res: express.Response) => {
try {
+200 -22
View File
@@ -45,6 +45,16 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
.im-reply .im-lang { font-size: 10px; color: #22c55e; font-weight: 700; margin-bottom: 3px; text-transform: uppercase; letter-spacing: .04em; }
.im-reply .im-tts { margin-left: 6px; }
.im-loading { align-self: flex-start; color: var(--muted); font-size: 12px; padding: 4px 0; }
.teacher-md-p { margin: 0 0 6px; }
.teacher-md-p:last-child { margin-bottom: 0; }
.teacher-md-heading { font-weight: 700; margin: 8px 0 4px; }
.teacher-md-heading:first-child { margin-top: 0; }
.teacher-md-list { margin: 0 0 6px; padding-left: 18px; }
.teacher-md-quote { margin: 0 0 6px; padding: 4px 10px; border-left: 3px solid var(--line); color: var(--muted); }
.teacher-md-hr { border: none; border-top: 1px solid var(--line); margin: 8px 0; }
.teacher-md-table { border-collapse: collapse; width: 100%; margin: 0 0 6px; font-size: 12px; }
.teacher-md-table th, .teacher-md-table td { border: 1px solid var(--line); padding: 4px 6px; text-align: left; }
.teacher-md-table th { background: var(--panel); }
.lg-interp-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
.lg-interp-input-row textarea { flex: 1; resize: none; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 13px; color: var(--text); outline: none; font-family: inherit; height: 38px; max-height: 90px; }
.lg-interp-input-row textarea:focus { border-color: var(--brand); }
@@ -290,6 +300,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<button class="lg-theme-btn" onclick="setTheme('warm')">🌿 웜</button>
</div>
<button class="lg-interp-toggle-btn" id="interp-toggle-btn" onclick="toggleInterpreter()">🗣️ 통역사</button>
<button class="lg-interp-toggle-btn" id="teacher-toggle-btn" onclick="toggleTeacher()">👨‍🏫 선생님</button>
<a href="/" style="font-size:12px;color:var(--muted);text-decoration:none;margin-left:4px">← 메인</a>
</div>
@@ -328,12 +339,8 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<!-- Sidebar content per nav -->
<div class="lg-nav-content" id="sb-flash">
<div class="lg-section-label">카테고리</div>
<button class="lg-cat-btn active" onclick="setCategory('all')" data-cat="all">전체</button>
<button class="lg-cat-btn" onclick="setCategory('greetings')" data-cat="greetings">인사</button>
<button class="lg-cat-btn" onclick="setCategory('numbers')" data-cat="numbers">숫자</button>
<button class="lg-cat-btn" onclick="setCategory('food')" data-cat="food">음식</button>
<button class="lg-cat-btn" onclick="setCategory('verbs')" data-cat="verbs">동사</button>
<button class="lg-cat-btn" onclick="setCategory('travel')" data-cat="travel">여행</button>
<div id="cat-buttons-flash"></div>
<button class="lg-cat-btn" onclick="addCategory()" style="border-style:dashed">+ 카테고리 추가</button>
<div class="lg-section-label" style="margin-top:8px">도구</div>
<button class="lg-cat-btn" onclick="shuffleDeck()">🔀 섞기</button>
<button class="lg-cat-btn" onclick="resetDeck()">↺ 다시 시작</button>
@@ -342,22 +349,13 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<div class="lg-nav-content" id="sb-vocab" style="display:none">
<div class="lg-section-label">필터</div>
<button class="lg-cat-btn active" onclick="setVocabCat('all')" data-vcat="all">전체</button>
<button class="lg-cat-btn" onclick="setVocabCat('greetings')" data-vcat="greetings">인사</button>
<button class="lg-cat-btn" onclick="setVocabCat('numbers')" data-vcat="numbers">숫자</button>
<button class="lg-cat-btn" onclick="setVocabCat('food')" data-vcat="food">음식</button>
<button class="lg-cat-btn" onclick="setVocabCat('verbs')" data-vcat="verbs">동사</button>
<button class="lg-cat-btn" onclick="setVocabCat('travel')" data-vcat="travel">여행</button>
<div id="cat-buttons-vocab"></div>
<button class="lg-cat-btn" onclick="addCategory()" style="border-style:dashed">+ 카테고리 추가</button>
</div>
<div class="lg-nav-content" id="sb-quiz" style="display:none">
<div class="lg-section-label">퀴즈 설정</div>
<button class="lg-cat-btn active" onclick="setQuizCat('all')" data-qcat="all">전체</button>
<button class="lg-cat-btn" onclick="setQuizCat('greetings')" data-qcat="greetings">인사</button>
<button class="lg-cat-btn" onclick="setQuizCat('numbers')" data-qcat="numbers">숫자</button>
<button class="lg-cat-btn" onclick="setQuizCat('food')" data-qcat="food">음식</button>
<button class="lg-cat-btn" onclick="setQuizCat('verbs')" data-qcat="verbs">동사</button>
<button class="lg-cat-btn" onclick="setQuizCat('travel')" data-qcat="travel">여행</button>
<div id="cat-buttons-quiz"></div>
</div>
<div class="lg-nav-content" id="sb-lessons" style="display:none">
@@ -378,9 +376,9 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--muted);cursor:pointer">
<input type="checkbox" id="show-rom" checked onchange="renderFlashView()" style="accent-color:#22c55e">발음 표시
</label>
<button class="lg-btn" id="view-toggle-btn" onclick="toggleViewMode()">🀄 타일로 보기</button>
<button class="lg-btn" id="view-toggle-btn" onclick="toggleViewMode()">🔄 한장씩 보기</button>
</div>
<div class="card-wrap" id="card-wrap-single">
<div class="card-wrap" id="card-wrap-single" style="display:none">
<div class="card-stage" onclick="flipCard()" id="card-stage">
<div class="card-inner" id="card-inner">
<div class="card-face card-front">
@@ -405,7 +403,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<span>학습 필요: <span class="unknown-val" id="unknown-count">0</span></span>
</div>
</div>
<div class="card-grid" id="card-grid" style="display:none"></div>
<div class="card-grid" id="card-grid"></div>
</div>
<!-- ═══ VOCAB PANEL ═══ -->
@@ -541,6 +539,22 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<button class="lg-interp-send" id="lg-interp-send-btn" onclick="sendInterpret()">전송</button>
</div>
</div>
<!-- Teacher chat panel: general Q&A (grammar/usage/culture), not translation -->
<div class="lg-interp" id="lg-teacher">
<div class="lg-interp-head">
<b>👨‍🏫 우즈베크어 선생님</b>
<span style="font-size:11px;color:var(--muted)">문법·단어·문화 질문</span>
<button class="lg-interp-close" onclick="toggleTeacher(false)" title="닫기">✕</button>
</div>
<div class="lg-interp-msgs" id="lg-teacher-msgs">
<div class="lg-interp-empty" id="lg-teacher-empty">궁금한 걸 물어보세요.<br>예: "이 문법 언제 써요?", "이 단어랑 이 단어 차이가 뭐예요?", "이 문장 자연스러운가요?"</div>
</div>
<div class="lg-interp-input-row">
<textarea id="lg-teacher-input" placeholder="질문을 입력하세요..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendTeacherMsg();}"></textarea>
<button class="lg-interp-send" id="lg-teacher-send-btn" onclick="sendTeacherMsg()">전송</button>
</div>
</div>
</div><!-- /lg-layout -->
<script>
@@ -549,6 +563,62 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
function escHtml(s){
return String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Lightweight markdown renderer for teacher replies (tables/bold/lists/blockquotes/headings) —
// escapes first so raw model text can never inject HTML, then builds block/inline markup on
// top of the already-escaped string (e.g. matching literal '>' via its escaped '&gt;' form).
function mdInline(s) {
return s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/`([^`]+?)`/g, '<code>$1</code>');
}
function renderTeacherMd(raw) {
const lines = escHtml(raw).split('\n');
const blocks = [];
let i = 0;
const isTable = l => /^\s*\|.*\|\s*$/.test(l);
const isQuote = l => /^\s*&gt;\s?/.test(l);
const isListItem = l => /^\s*[-*]\s+/.test(l);
const isHeading = l => /^\s*#{1,4}\s+/.test(l);
const isHr = l => /^\s*-{3,}\s*$/.test(l);
while (i < lines.length) {
const line = lines[i];
if (isTable(line)) {
const rows = [];
while (i < lines.length && isTable(lines[i])) { rows.push(lines[i].trim()); i++; }
const dataRows = rows.filter(l => !/^\|[\s:|-]+\|$/.test(l));
const cellsOf = l => l.replace(/^\||\|$/g, '').split('|').map(c => mdInline(c.trim()));
if (dataRows.length) {
const [head, ...body] = dataRows;
let t = '<table class="teacher-md-table"><thead><tr>' + cellsOf(head).map(c => `<th>${c}</th>`).join('') + '</tr></thead><tbody>';
for (const r of body) t += '<tr>' + cellsOf(r).map(c => `<td>${c}</td>`).join('') + '</tr>';
blocks.push(t + '</tbody></table>');
}
continue;
}
if (isQuote(line)) {
const qLines = [];
while (i < lines.length && isQuote(lines[i])) { qLines.push(lines[i].replace(/^\s*&gt;\s?/, '')); i++; }
blocks.push('<blockquote class="teacher-md-quote">' + mdInline(qLines.join('<br>')) + '</blockquote>');
continue;
}
if (isListItem(line)) {
const items = [];
while (i < lines.length && isListItem(lines[i])) { items.push(lines[i].replace(/^\s*[-*]\s+/, '')); i++; }
blocks.push('<ul class="teacher-md-list">' + items.map(it => `<li>${mdInline(it)}</li>`).join('') + '</ul>');
continue;
}
if (isHeading(line)) {
blocks.push(`<div class="teacher-md-heading">${mdInline(line.replace(/^\s*#{1,4}\s+/, ''))}</div>`);
i++; continue;
}
if (isHr(line)) { blocks.push('<hr class="teacher-md-hr">'); i++; continue; }
if (/^\s*$/.test(line)) { i++; continue; }
const pLines = [];
while (i < lines.length && lines[i].trim() && !isTable(lines[i]) && !isQuote(lines[i]) && !isListItem(lines[i]) && !isHeading(lines[i]) && !isHr(lines[i])) {
pLines.push(lines[i]); i++;
}
blocks.push('<div class="teacher-md-p">' + mdInline(pLines.join('<br>')) + '</div>');
}
return blocks.join('');
}
// ── Mobile ────────────────────────────────────────────────────────────────────
function mobCloseFilter(){
document.querySelector('.lg-sidebar').classList.remove('mob-open');
@@ -561,6 +631,7 @@ function mobOpenFilter(){
function mobCloseAll(){
mobCloseFilter();
toggleInterpreter(false);
toggleTeacher(false);
}
function mobNav(nav){
mobCloseFilter();
@@ -581,6 +652,42 @@ function mobNav(nav){
// DATA
// ═══════════════════════════════════════════════════════
const CAT_LABELS = { greetings:'인사', numbers:'숫자', food:'음식', verbs:'동사', travel:'여행' };
const DEFAULT_CATEGORY_KEYS = Object.keys(CAT_LABELS);
// User-added categories persist in localStorage as a plain string list — the string IS both
// the key and the label (no separate slug), so "가족"/"날씨" etc. work without asking the
// user to think about internal naming. Merged into CAT_LABELS on load so every existing
// lookup (CAT_LABELS[v.cat]) already works for custom categories with no other code changes.
let CUSTOM_CATEGORIES = [];
try { CUSTOM_CATEGORIES = JSON.parse(localStorage.getItem('uz_custom_categories') || '[]').filter(c => typeof c === 'string' && c.trim()); } catch { CUSTOM_CATEGORIES = []; }
for (const c of CUSTOM_CATEGORIES) CAT_LABELS[c] = c;
function renderCategoryButtons() {
const specs = [
{ containerId: 'cat-buttons-flash', attr: 'data-cat', active: STATE.category, fn: 'setCategory' },
{ containerId: 'cat-buttons-vocab', attr: 'data-vcat', active: STATE.vocabCat, fn: 'setVocabCat' },
{ containerId: 'cat-buttons-quiz', attr: 'data-qcat', active: STATE.quizCat, fn: 'setQuizCat' },
];
const keys = ['all', ...DEFAULT_CATEGORY_KEYS, ...CUSTOM_CATEGORIES];
for (const s of specs) {
const el = document.getElementById(s.containerId);
if (!el) continue;
el.innerHTML = keys.map(k => {
const label = k === 'all' ? '전체' : (CAT_LABELS[k] || k);
const activeCls = k === s.active ? ' active' : '';
return `<button class="lg-cat-btn${activeCls}" onclick="${s.fn}('${k.replace(/'/g, "\\'")}')" ${s.attr}="${escHtml(k)}">${escHtml(label)}</button>`;
}).join('');
}
}
function addCategory() {
const name = (prompt('추가할 카테고리 이름을 입력하세요 (예: 가족, 날씨, 직업)') || '').trim();
if (!name) return;
if (CAT_LABELS[name]) { alert('이미 있는 카테고리입니다.'); return; }
CUSTOM_CATEGORIES.push(name);
CAT_LABELS[name] = name;
localStorage.setItem('uz_custom_categories', JSON.stringify(CUSTOM_CATEGORIES));
renderCategoryButtons();
}
const VOCAB_BASE = [
// 인사
@@ -736,7 +843,7 @@ let STATE = {
category: 'all',
vocabCat: 'all',
quizCat: 'all',
viewMode: 'single', // 'single' | 'grid'
viewMode: 'grid', // 'single' | 'grid'
deck: [],
deckIdx: 0,
known: new Set(),
@@ -1285,6 +1392,7 @@ function toggleInterpreter(force) {
const open = typeof force === 'boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (open) toggleTeacher(false); // only one side panel at a time
if (window.innerWidth <= 1024) {
document.getElementById('mob-backdrop').classList.toggle('show', open);
}
@@ -1355,6 +1463,75 @@ async function sendInterpret() {
}
}
// ═══════════════════════════════════════════════════════
// TEACHER (general Q&A chat — grammar/usage/culture, not translation)
// ═══════════════════════════════════════════════════════
let _teacherBusy = false;
let TEACHER_HISTORY = []; // [{role:'user'|'assistant', content}] — resent each turn, capped client-side too
function toggleTeacher(force) {
const panel = document.getElementById('lg-teacher');
const btn = document.getElementById('teacher-toggle-btn');
const open = typeof force === 'boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (open) toggleInterpreter(false); // only one side panel at a time
if (window.innerWidth <= 1024) {
document.getElementById('mob-backdrop').classList.toggle('show', open);
}
if (open) setTimeout(() => document.getElementById('lg-teacher-input')?.focus(), 50);
}
function teacherAppend(html) {
const msgs = document.getElementById('lg-teacher-msgs');
const empty = document.getElementById('lg-teacher-empty');
if (empty) empty.remove();
const wrap = document.createElement('div');
wrap.innerHTML = html;
const node = wrap.firstElementChild;
msgs.appendChild(node);
msgs.scrollTop = msgs.scrollHeight;
return node;
}
async function sendTeacherMsg() {
if (_teacherBusy) return;
const inp = document.getElementById('lg-teacher-input');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
inp.style.height = '38px';
teacherAppend(`<div class="im-bubble im-user">${escHtml(text)}</div>`);
_teacherBusy = true;
document.getElementById('lg-teacher-send-btn').disabled = true;
const loading = teacherAppend(`<div class="im-loading">💬 생각 중...</div>`);
try {
const res = await fetch('/api/language/ask-teacher', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, history: TEACHER_HISTORY })
});
const d = await res.json();
loading.remove();
if (!d.success) {
teacherAppend(`<div class="im-bubble im-reply" style="color:#f87171">답변 실패: ${escHtml((d.error||'').toString().slice(0,120))}</div>`);
return;
}
const reply = String(d.reply || '');
const node = teacherAppend(`<div class="im-bubble im-reply">
${renderTeacherMd(reply)}
<button class="tts-btn im-tts" title="발음 듣기" style="margin-top:4px">🔊</button>
</div>`);
const ttsBtn = node.querySelector('.im-tts');
if (ttsBtn) ttsBtn.onclick = () => speakLang(reply, 'ko', ttsBtn);
TEACHER_HISTORY.push({ role: 'user', content: text }, { role: 'assistant', content: reply });
if (TEACHER_HISTORY.length > 20) TEACHER_HISTORY = TEACHER_HISTORY.slice(-20);
} catch (e) {
loading.remove();
teacherAppend(`<div class="im-bubble im-reply" style="color:#f87171">네트워크 오류: ${escHtml(String(e.message||e).slice(0,120))}</div>`);
} finally {
_teacherBusy = false;
document.getElementById('lg-teacher-send-btn').disabled = false;
}
}
// speakLang: TTS for either language. uz → /api/language/tts (edge-tts uz voices),
// ko → /api/voice/tts (configured Korean engine, currently OmniVoice GPU).
// Returns a Promise that resolves when playback finishes (used by call mode to
@@ -1542,6 +1719,7 @@ function updateStatChip() {
// INIT
// ═══════════════════════════════════════════════════════
loadProgress();
renderCategoryButtons();
buildDeck('all');
updateStatChip();
</script>