v4.1.13: 언어앱 타일뷰 기본화 + 카테고리 추가 + 선생님 Q&A 패널
기본 보기를 타일 그리드로 전환하고 사용자가 카테고리를 직접 추가할 수 있게 함. 문법/단어/문화 질문에 답하는 "선생님" 채팅 패널 신설(/api/language/ask-teacher). 선생님 답변의 마크다운(표·볼드·목록·인용)이 그대로 노출되던 버그를 자체 렌더러로 수정.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 '>' 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*>\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*>\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>
|
||||
|
||||
Reference in New Issue
Block a user