v4.1.12: 보안 취약점 수정 — RCE, 경로탈출 2건, IDOR, XSS(스튜디오/언어 앱)

- [CRITICAL] image_edit/imagegen 도구의 숫자 파라미터(x,y,width,height,degrees,opacity,quality,seed,guidance_scale,num_frames,fps)가 검증 없이 Python 소스에 직접 문자열 삽입되어 원격 코드 실행 가능했음. toFiniteNumber()로 전 지점 강제 숫자 변환 — 실제 페이로드로 라이브 검증 완료
- [HIGH] imageStyleTransformTool과 /api/imagegen/save-result에 경로 탈출 방어(isPathInsideDir) 누락 — 다른 사용자 워크스페이스/임의 파일 접근 가능했음. 둘 다 수정 후 실제 ../ 페이로드로 차단 확인
- [HIGH] 언어 앱 진행상황 저장 API가 URL의 :userId를 그대로 신뢰해 다른 사용자 진행상황을 읽고 덮어쓸 수 있었음(IDOR). 세션 기반으로 수정, 실제 다른 사용자명으로 테스트해 본인 워크스페이스에만 저장됨을 확인
- [MEDIUM] 언어 앱 플래시카드·단어장 렌더링(LLM 생성 콘텐츠)이 이스케이프 없이 innerHTML에 삽입되던 XSS 경로 수정(escHtml 추가)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-16 15:04:20 +09:00
co-authored by Claude Sonnet 5
parent 5b089ac297
commit edd7816048
6 changed files with 411 additions and 35 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
"version": "4.1.11",
"version": "4.1.12",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
+44 -2
View File
@@ -104,9 +104,14 @@ export function registerLanguageRoutes(app: express.Application): void {
}
});
// NOTE: the :userId route param is intentionally ignored for workspace resolution —
// progress always belongs to the authenticated session, never an arbitrary URL param,
// otherwise any logged-in user could read/overwrite any other user's progress file.
app.get('/api/language/progress/:userId', (req: express.Request, res: express.Response) => {
try {
const workspace = getWorkspacePath(String(req.params.userId));
const sessionUser = (req as any).user;
if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
const workspace = getWorkspacePath(sessionUser.username);
const progressPath = path.join(workspace, 'language', 'progress.json');
if (!fs.existsSync(progressPath)) { res.json({ success: true, progress: null }); return; }
res.json({ success: true, progress: JSON.parse(fs.readFileSync(progressPath, 'utf-8')) });
@@ -117,9 +122,11 @@ export function registerLanguageRoutes(app: express.Application): void {
app.post('/api/language/progress/:userId', (req: express.Request, res: express.Response) => {
try {
const sessionUser = (req as any).user;
if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
const body = req.body;
if (!body || typeof body !== 'object') { res.status(400).json({ success: false, error: 'body required' }); return; }
const workspace = getWorkspacePath(String(req.params.userId));
const workspace = getWorkspacePath(sessionUser.username);
const langDir = path.join(workspace, 'language');
fs.mkdirSync(langDir, { recursive: true });
fs.writeFileSync(path.join(langDir, 'progress.json'), JSON.stringify(body, null, 2), 'utf-8');
@@ -129,6 +136,41 @@ export function registerLanguageRoutes(app: express.Application): void {
}
});
// POST /api/language/interpret — 실시간 한국어↔우즈베크어 통역 (언어 자동 판별)
app.post('/api/language/interpret', async (req: express.Request, res: express.Response) => {
try {
const text = String(req.body?.text || '').trim().slice(0, 500);
if (!text) { res.status(400).json({ success: false, error: 'text required' }); return; }
const ollama = getOllamaClient();
const prompt = `다음 텍스트가 한국어인지 우즈베크어인지 판별하고, 반대 언어로 번역하세요.
텍스트: "${text}"
JSON으로만 응답하세요 (코드 블록 없이):
{"source_lang":"ko 또는 uz","translated":"번역문","romanization":"번역문이 우즈베크어면 한글 발음 표기, 한국어면 빈 문자열"}
우즈베크어는 1993년 이후 표준 라틴 문자(oʻ, gʻ 포함)를 사용하세요. 자연스럽고 간결하게 번역하세요.`;
const result = await ollama.chatWithThinking(
[{ role: 'user' as const, content: prompt }],
'executor',
{ num_predict: 400, temperature: 0.2, think: false }
);
const raw = String(result.message?.content || '').trim();
let parsed: any;
try { parsed = extractJson(raw, 'object'); } catch { parsed = null; }
if (!parsed?.translated) {
res.json({ success: false, error: '번역 실패', raw }); return;
}
res.json({
success: true,
source_lang: parsed.source_lang === 'uz' ? 'uz' : 'ko',
translated: String(parsed.translated),
romanization: String(parsed.romanization || ''),
});
} 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 {
+2 -1
View File
@@ -2,7 +2,7 @@ import { Express, Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js';
import { imageEditTool } from '../../tools/image.js';
import { imageEditTool, isPathInsideDir } from '../../tools/image.js';
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const VIDEO_EXTS = new Set(['.mp4']);
@@ -232,6 +232,7 @@ export function registerImagegenRoutes(app: Express): void {
if (!srcRel) return res.status(400).json({ error: 'path is required' });
const srcFull = path.join(user.workspace, srcRel);
if (!isPathInsideDir(user.workspace, srcFull)) return res.status(403).json({ error: 'Access denied: path escapes workspace' });
if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' });
console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`);
+20 -9
View File
@@ -4,7 +4,7 @@ import fs from 'fs';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
function isPathInsideDir(base: string, target: string): boolean {
export function isPathInsideDir(base: string, target: string): boolean {
const resolvedBase = path.resolve(base);
const resolvedTarget = path.resolve(target);
if (resolvedBase === resolvedTarget) return true;
@@ -12,6 +12,17 @@ function isPathInsideDir(base: string, target: string): boolean {
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
// EDIT_SCRIPT interpolates these directly into Python source (e.g. `int(${params.x})`) run
// via `python3 -c <script>` — an unvalidated string like `0)); import os; os.system(...); x=(0`
// breaks out of the int()/float() call and executes as arbitrary Python. Every numeric field
// that reaches that template MUST be coerced through this first; non-finite input silently
// falls back to the default rather than erroring, since these are cosmetic edit params, not
// something worth failing the whole request over.
export function toFiniteNumber(value: any, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif']);
function runPython(script: string, timeoutMs = 60_000): Promise<any> {
@@ -717,18 +728,18 @@ export const imageEditTool = {
src: resolved,
dst: outPath,
operation,
x: args?.x ?? 0,
y: args?.y ?? 0,
width: args?.width ?? 0,
height: args?.height ?? 0,
x: toFiniteNumber(args?.x, 0),
y: toFiniteNumber(args?.y, 0),
width: toFiniteNumber(args?.width, 0),
height: toFiniteNumber(args?.height, 0),
keep_aspect: args?.keep_aspect ?? false,
degrees: args?.degrees ?? 0,
degrees: toFiniteNumber(args?.degrees, 0),
direction: args?.direction ?? 'horizontal',
value: args?.value ?? 1.0,
value: toFiniteNumber(args?.value, 1.0),
text: args?.text ?? '',
position: args?.position ?? 'bottom-right',
opacity: args?.opacity ?? 0.5,
quality: args?.quality ?? 92,
opacity: toFiniteNumber(args?.opacity, 0.5),
quality: toFiniteNumber(args?.quality, 92),
style: args?.style ?? 'painting',
preset: args?.preset ?? 'warm',
};
+9 -8
View File
@@ -3,7 +3,7 @@ import path from 'path';
import fs from 'fs';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown } from './image.js';
import { buildImageMarkdown, toFiniteNumber, isPathInsideDir } from './image.js';
import { getOllamaConfig } from './web.js';
// SDXL/LTX-Video's text encoders (CLIP/T5) are trained overwhelmingly on English
@@ -334,6 +334,7 @@ export const imageStyleTransformTool = {
const workspacePath = getWorkspacePath(args);
const srcPath = path.isAbsolute(imageArg) ? imageArg : path.resolve(workspacePath, imageArg);
if (!isPathInsideDir(workspacePath, srcPath)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(srcPath)) return { success: false, error: `Source image not found: ${imageArg}` };
let outPath = String(args?.output || '').trim();
@@ -349,8 +350,8 @@ export const imageStyleTransformTool = {
negative_prompt: preset.negative,
strength: Math.min(1, Math.max(0.05, args?.strength ?? preset.strength)),
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
guidance_scale: args?.guidance_scale ?? preset.guidance_scale,
seed: args?.seed,
guidance_scale: toFiniteNumber(args?.guidance_scale, preset.guidance_scale),
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
face_lock: args?.face_lock ?? true,
max_side: 1024,
dst: outPath,
@@ -438,7 +439,7 @@ export const imageGenerateTool = {
prompt: translatedPrompt || prompt,
width, height,
steps: Math.min(8, Math.max(1, args?.steps ?? 4)),
seed: args?.seed,
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
dst: outPath,
};
result = await runVenvPython(FLUX_SCRIPT(params), 180_000);
@@ -448,8 +449,8 @@ export const imageGenerateTool = {
negative_prompt: translatedNegative || negativePromptRaw,
width, height,
steps: Math.min(50, Math.max(15, args?.steps ?? 30)),
guidance_scale: args?.guidance_scale ?? 7.0,
seed: args?.seed,
guidance_scale: toFiniteNumber(args?.guidance_scale, 7.0),
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
dst: outPath,
};
result = await runVenvPython(SDXL_SCRIPT(params), 180_000);
@@ -566,8 +567,8 @@ export const videoGenerateTool = {
negative_prompt: translatedNegative || negativePromptRaw,
width: Math.round((args?.width ?? 704) / 32) * 32,
height: Math.round((args?.height ?? 480) / 32) * 32,
num_frames: args?.num_frames ?? 65,
fps: args?.fps ?? 24,
num_frames: toFiniteNumber(args?.num_frames, 65),
fps: toFiniteNumber(args?.fps, 24),
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
guidance_scale: Math.min(10, Math.max(1, args?.guidance_scale ?? 3.0)),
dst: outPath,
+335 -14
View File
@@ -29,6 +29,43 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
/* ── Layout ── */
.lg-layout { flex: 1; display: flex; min-height: 0; }
/* ── Interpreter panel (right dock) ── */
.lg-interp { width: 320px; flex-shrink: 0; display: none; flex-direction: column; border-left: 1px solid var(--line); background: var(--panel); min-height: 0; }
.lg-interp.open { display: flex; }
.lg-interp-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--panel-2); flex-shrink: 0; }
.lg-interp-head b { font-size: 13px; flex: 1; }
.lg-interp-close { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 2px 6px; }
.lg-interp-close:hover { color: var(--text); }
.lg-interp-msgs { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 10px; }
.lg-interp-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 12px; line-height: 1.6; }
.im-bubble { max-width: 88%; border-radius: 12px; padding: 8px 11px; font-size: 13px; line-height: 1.5; }
.im-user { align-self: flex-end; background: #166534; color: #fff; border-bottom-right-radius: 3px; }
.im-reply { align-self: flex-start; background: var(--panel-2); border: 1px solid var(--line); border-bottom-left-radius: 3px; }
.im-reply .im-rom { font-size: 11px; color: var(--muted); font-style: italic; margin-top: 3px; }
.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; }
.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); }
.lg-interp-send { background: #166534; border: 1px solid #22c55e; color: #fff; border-radius: 8px; padding: 0 14px; font-size: 13px; font-weight: 700; cursor: pointer; flex-shrink: 0; }
.lg-interp-send:hover { filter: brightness(1.15); }
.lg-interp-send:disabled { opacity: .5; cursor: default; }
.lg-interp-toggle-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 7px; padding: 5px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit; transition: .15s; white-space: nowrap; }
.lg-interp-toggle-btn:hover, .lg-interp-toggle-btn.active { border-color: #22c55e; color: #22c55e; }
.lg-mic-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; width: 38px; height: 38px; flex-shrink: 0; font-size: 15px; cursor: pointer; }
.lg-mic-btn:hover { border-color: #22c55e; }
.lg-mic-btn.recording { border-color: #ef4444; color: #ef4444; animation: lg-mic-pulse .8s infinite alternate; background: rgba(239,68,68,.08); }
@keyframes lg-mic-pulse { from { opacity: .6; } to { opacity: 1; } }
.lg-call-btn { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 2px 6px; }
.lg-call-btn:hover { color: #22c55e; }
.lg-call-btn.active { color: #ef4444; animation: lg-mic-pulse .8s infinite alternate; }
.lg-call-status { font-size: 11px; color: #22c55e; padding: 0 12px 8px; min-height: 14px; flex-shrink: 0; }
@media(max-width:1024px){
.lg-interp { position: fixed; top: 0; right: 0; bottom: 0; z-index: 700; width: 88vw; max-width: 360px; box-shadow: -4px 0 24px rgba(0,0,0,.45); }
}
/* ── Sidebar ── */
.lg-sidebar { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); min-height: 0; }
.csb-tab-bar { display: flex; border-bottom: 2px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
@@ -252,10 +289,11 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
<button class="lg-theme-btn" onclick="setTheme('light')">☀️ 라이트</button>
<button class="lg-theme-btn" onclick="setTheme('warm')">🌿 웜</button>
</div>
<button class="lg-interp-toggle-btn" id="interp-toggle-btn" onclick="toggleInterpreter()">🗣️ 통역사</button>
<a href="/" style="font-size:12px;color:var(--muted);text-decoration:none;margin-left:4px">← 메인</a>
</div>
<div id="mob-backdrop" onclick="mobCloseFilter()"></div>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-flash" onclick="mobNav('flash')">
@@ -484,9 +522,33 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
</div>
</div><!-- /lg-main -->
<!-- Interpreter panel -->
<div class="lg-interp" id="lg-interp">
<div class="lg-interp-head">
<b>🗣️ 실시간 통역사</b>
<span style="font-size:11px;color:var(--muted)">한국어 ⇄ 우즈베크어</span>
<button class="lg-call-btn" id="lg-call-btn" onclick="toggleCall()" title="통역 통화 시작/종료">📞</button>
<button class="lg-interp-close" onclick="toggleInterpreter(false)" title="닫기">✕</button>
</div>
<div class="lg-call-status" id="lg-call-status"></div>
<div class="lg-interp-msgs" id="lg-interp-msgs">
<div class="lg-interp-empty" id="lg-interp-empty">문장을 입력하거나 🎤/📞로 말하면 한국어↔우즈베크어를 자동으로 판별해서 통역해드려요.<br>예: "화장실이 어디예요?" 또는 "Rahmat"</div>
</div>
<div class="lg-interp-input-row">
<button class="lg-mic-btn" id="lg-mic-btn" onclick="toggleMicInput()" title="음성으로 입력">🎤</button>
<textarea id="lg-interp-input" placeholder="한국어 또는 우즈베크어로 입력..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInterpret();}"></textarea>
<button class="lg-interp-send" id="lg-interp-send-btn" onclick="sendInterpret()">전송</button>
</div>
</div>
</div><!-- /lg-layout -->
<script>
// Vocab/flashcard/quiz content can come from generate-flashcards (LLM output) — never trust
// it as HTML. Escape before every innerHTML insertion of model- or user-derived text.
function escHtml(s){
return String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// ── Mobile ────────────────────────────────────────────────────────────────────
function mobCloseFilter(){
document.querySelector('.lg-sidebar').classList.remove('mob-open');
@@ -496,6 +558,10 @@ function mobOpenFilter(){
document.querySelector('.lg-sidebar').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
}
function mobCloseAll(){
mobCloseFilter();
toggleInterpreter(false);
}
function mobNav(nav){
mobCloseFilter();
setNav(nav);
@@ -703,8 +769,9 @@ function saveProgress() {
known: [...STATE.known],
lessonsDone: STATE.lessonsDone,
}));
// also sync to server (fire-and-forget)
fetch('/api/language/progress/papa', {
// also sync to server (fire-and-forget). The URL segment is a leftover route param —
// the server derives the actual user from the session cookie, not this value.
fetch('/api/language/progress/session', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ known: [...STATE.known], lessonsDone: STATE.lessonsDone })
}).catch(()=>{});
@@ -779,13 +846,13 @@ function renderGrid() {
<div class="tile-stage" onclick="this.querySelector('.tile-inner').classList.toggle('flipped')">
<div class="tile-inner">
<div class="tile-face tile-front">
<span class="tile-cat">${CAT_LABELS[v.cat]||''}</span>
<div class="tile-uz">${v.uz}</div>
${showRom?`<div class="tile-rom">[${v.rom}]</div>`:''}
<button class="tts-btn tile-tts-btn" data-uz="${v.uz.replace(/"/g,'&quot;')}" onclick="event.stopPropagation();speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
<span class="tile-cat">${escHtml(CAT_LABELS[v.cat]||'')}</span>
<div class="tile-uz">${escHtml(v.uz)}</div>
${showRom?`<div class="tile-rom">[${escHtml(v.rom)}]</div>`:''}
<button class="tts-btn tile-tts-btn" data-uz="${escHtml(v.uz)}" onclick="event.stopPropagation();speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
</div>
<div class="tile-face tile-back">
<div class="tile-ko">${v.ko}</div>
<div class="tile-ko">${escHtml(v.ko)}</div>
</div>
</div>
</div>
@@ -909,15 +976,15 @@ function renderVocabTable() {
<td>
<div style="display:flex;align-items:center;gap:6px">
<div>
<div class="vocab-uz">${v.uz}</div>
${showRom?`<div class="vocab-rom">[${v.rom}]</div>`:''}
<div class="vocab-uz">${escHtml(v.uz)}</div>
${showRom?`<div class="vocab-rom">[${escHtml(v.rom)}]</div>`:''}
</div>
<button class="tts-btn" data-uz="${v.uz.replace(/"/g,'&quot;')}" onclick="speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
<button class="tts-btn" data-uz="${escHtml(v.uz)}" onclick="speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
</div>
</td>
${showRom?`<td style="color:var(--muted);font-size:12px;font-style:italic">[${v.rom}]</td>`:''}
<td>${v.ko}</td>
<td><span class="vocab-cat">${CAT_LABELS[v.cat]||v.cat}</span></td>
${showRom?`<td style="color:var(--muted);font-size:12px;font-style:italic">[${escHtml(v.rom)}]</td>`:''}
<td>${escHtml(v.ko)}</td>
<td><span class="vocab-cat">${escHtml(CAT_LABELS[v.cat]||v.cat)}</span></td>
</tr>
`).join('');
}
@@ -1208,6 +1275,260 @@ function speakCurrent() {
if (v) speak(v.uz, document.getElementById('card-tts-btn'));
}
// ═══════════════════════════════════════════════════════
// INTERPRETER (real-time ko ⇄ uz chat panel + voice/call mode)
// ═══════════════════════════════════════════════════════
let _interpBusy = false;
function toggleInterpreter(force) {
const panel = document.getElementById('lg-interp');
const btn = document.getElementById('interp-toggle-btn');
const open = typeof force === 'boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (window.innerWidth <= 1024) {
document.getElementById('mob-backdrop').classList.toggle('show', open);
}
if (open) setTimeout(() => document.getElementById('lg-interp-input')?.focus(), 50);
}
function interpAppend(html) {
const msgs = document.getElementById('lg-interp-msgs');
const empty = document.getElementById('lg-interp-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;
}
// Calls /api/language/interpret, renders the reply bubble (with TTS button for
// either language), and returns {translated, targetLang} for callers (call mode)
// that need to auto-play the result. Returns null on failure.
async function interpretAndRender(text) {
const loading = interpAppend(`<div class="im-loading">💬 통역 중...</div>`);
try {
const res = await fetch('/api/language/interpret', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const d = await res.json();
loading.remove();
if (!d.success) {
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">통역 실패: ${(d.error||'').toString().slice(0,120)}</div>`);
return null;
}
const targetLang = d.source_lang === 'ko' ? 'uz' : 'ko';
const langLabel = targetLang === 'uz' ? '🇺🇿 우즈베크어' : '🇰🇷 한국어';
const translated = String(d.translated || '');
const rom = String(d.romanization || '').replace(/</g,'&lt;');
const node = interpAppend(`<div class="im-bubble im-reply">
<div class="im-lang">${langLabel}</div>
<div>${translated.replace(/</g,'&lt;')}<button class="tts-btn im-tts" title="발음 듣기">🔊</button></div>
${rom ? `<div class="im-rom">[${rom}]</div>` : ''}
</div>`);
const ttsBtn = node.querySelector('.im-tts');
if (ttsBtn) ttsBtn.onclick = () => speakLang(translated, targetLang, ttsBtn);
return { translated, targetLang };
} catch (e) {
loading.remove();
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">네트워크 오류: ${String(e.message||e).slice(0,120)}</div>`);
return null;
}
}
async function sendInterpret() {
if (_interpBusy) return;
const inp = document.getElementById('lg-interp-input');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
inp.style.height = '38px';
interpAppend(`<div class="im-bubble im-user">${text.replace(/</g,'&lt;')}</div>`);
_interpBusy = true;
document.getElementById('lg-interp-send-btn').disabled = true;
try { await interpretAndRender(text); }
finally {
_interpBusy = false;
document.getElementById('lg-interp-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
// know when it's safe to start listening again).
let _ttsLangAudio = null;
function speakLang(text, lang, btn) {
return new Promise(async (resolve) => {
if (!text) { resolve(); return; }
if (_ttsLangAudio) { try { _ttsLangAudio.pause(); } catch {} _ttsLangAudio = null; }
if (btn) btn.classList.add('playing');
try {
const url = lang === 'uz' ? '/api/language/tts' : '/api/voice/tts';
const body = lang === 'uz' ? { text, gender: _ttsGender } : { text };
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('TTS 실패');
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
_ttsLangAudio = new Audio(objUrl);
_ttsLangAudio.onended = () => { if (btn) btn.classList.remove('playing'); URL.revokeObjectURL(objUrl); resolve(); };
_ttsLangAudio.onerror = () => { if (btn) btn.classList.remove('playing'); resolve(); };
_ttsLangAudio.play();
} catch (e) {
if (btn) btn.classList.remove('playing');
console.error('speakLang error:', e);
resolve();
}
});
}
// ── Single-shot mic input (fills the textarea, doesn't auto-send) ──
let _micRecorder = null, _micStream = null, _micChunks = [];
async function toggleMicInput() {
const btn = document.getElementById('lg-mic-btn');
if (_micRecorder && _micRecorder.state === 'recording') {
_micRecorder.stop();
return;
}
try {
_micStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert('마이크 권한이 필요합니다: ' + e.message);
return;
}
_micChunks = [];
_micRecorder = new MediaRecorder(_micStream, { mimeType: 'audio/webm' });
_micRecorder.ondataavailable = e => { if (e.data.size > 0) _micChunks.push(e.data); };
_micRecorder.onstop = async () => {
btn.classList.remove('recording');
try { _micStream.getTracks().forEach(t => t.stop()); } catch {}
const blob = new Blob(_micChunks, { type: 'audio/webm' });
if (blob.size < 1000) return;
btn.disabled = true;
try {
const res = await fetch('/api/voice/stt', { method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: blob });
const d = await res.json();
if (d.success && d.text) {
const inp = document.getElementById('lg-interp-input');
inp.value = (inp.value ? inp.value + ' ' : '') + d.text;
inp.focus();
}
} catch (e) { console.error('STT error:', e); }
finally { btn.disabled = false; }
};
_micRecorder.start();
btn.classList.add('recording');
}
// ── Call mode: continuous listen → STT → translate → speak → repeat ──
const CALL_RMS_ON = 0.020, CALL_RMS_OFF = 0.012;
const CALL_SPEECH_CONFIRM_FRAMES = 6, CALL_SILENCE_HANGOVER_MS = 900;
let callActive = false, callWakeLock = null, callMicStream = null, callAudioCtx = null, callAnalyser = null;
let callRecorder = null, callChunks = [], callState = 'idle', callSpeechFrames = 0, callSilenceMs = 0, callVadRaf = null;
const _callVadData = new Uint8Array(512);
async function callAcquireWakeLock() {
if (!('wakeLock' in navigator)) return;
try { callWakeLock = await navigator.wakeLock.request('screen'); callWakeLock.addEventListener('release', () => { callWakeLock = null; }); } catch {}
}
document.addEventListener('visibilitychange', () => {
if (callActive && callWakeLock === null && document.visibilityState === 'visible') callAcquireWakeLock();
});
function setCallStatus(text) { const el = document.getElementById('lg-call-status'); if (el) el.textContent = text || ''; }
function toggleCall() { if (callActive) hangupCall(); else startCall(); }
async function startCall() {
try {
callMicStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert('마이크 권한이 필요합니다: ' + e.message);
return;
}
callActive = true;
callAcquireWakeLock();
toggleInterpreter(true);
document.getElementById('lg-call-btn').classList.add('active');
document.getElementById('lg-call-btn').textContent = '📵';
callAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = callAudioCtx.createMediaStreamSource(callMicStream);
callAnalyser = callAudioCtx.createAnalyser();
callAnalyser.fftSize = 512;
source.connect(callAnalyser);
beginCallListening();
}
function hangupCall() {
callActive = false;
if (callVadRaf) cancelAnimationFrame(callVadRaf);
callVadRaf = null;
try { callRecorder && callRecorder.state !== 'inactive' && callRecorder.stop(); } catch {}
try { callMicStream && callMicStream.getTracks().forEach(t => t.stop()); } catch {}
try { callAudioCtx && callAudioCtx.close(); } catch {}
try { callWakeLock && callWakeLock.release(); } catch {}
callWakeLock = null; callMicStream = null; callAudioCtx = null; callAnalyser = null; callRecorder = null;
callState = 'idle';
setCallStatus('');
const btn = document.getElementById('lg-call-btn');
if (btn) { btn.classList.remove('active'); btn.textContent = '📞'; }
}
function beginCallListening() {
if (!callActive) return;
callState = 'listening';
callSpeechFrames = 0; callSilenceMs = 0;
setCallStatus('🎙️ 듣는 중...');
callChunks = [];
callRecorder = new MediaRecorder(callMicStream, { mimeType: 'audio/webm' });
callRecorder.ondataavailable = e => { if (e.data.size > 0) callChunks.push(e.data); };
callRecorder.onstop = onCallRecordingStop;
callRecorder.start();
runCallVadLoop();
}
function runCallVadLoop() {
if (!callActive || (callState !== 'listening' && callState !== 'speaking')) return;
callAnalyser.getByteTimeDomainData(_callVadData);
let sumSq = 0;
for (let i = 0; i < _callVadData.length; i++) { const v = (_callVadData[i] - 128) / 128; sumSq += v * v; }
const rms = Math.sqrt(sumSq / _callVadData.length);
const FRAME_MS = 16;
if (callState === 'listening') {
if (rms > CALL_RMS_ON) { callSpeechFrames++; if (callSpeechFrames > CALL_SPEECH_CONFIRM_FRAMES) { callState = 'speaking'; setCallStatus('🗣️ 말하는 중...'); } }
else callSpeechFrames = 0;
} else if (callState === 'speaking') {
if (rms < CALL_RMS_OFF) { callSilenceMs += FRAME_MS; if (callSilenceMs > CALL_SILENCE_HANGOVER_MS) { stopCallListeningAndProcess(); return; } }
else callSilenceMs = 0;
}
callVadRaf = requestAnimationFrame(runCallVadLoop);
}
function stopCallListeningAndProcess() {
if (callState === 'processing') return;
callState = 'processing';
setCallStatus('💬 통역 중...');
try { callRecorder.stop(); } catch {}
}
async function onCallRecordingStop() {
if (!callActive) return;
const blob = new Blob(callChunks, { type: 'audio/webm' });
if (blob.size < 2000) { beginCallListening(); return; }
try {
const sttRes = await fetch('/api/voice/stt', { method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: blob });
const sttData = await sttRes.json();
const text = (sttData.text || '').trim();
if (!text) { beginCallListening(); return; }
interpAppend(`<div class="im-bubble im-user">🎙️ ${text.replace(/</g,'&lt;')}</div>`);
const result = await interpretAndRender(text);
if (result && callActive) {
setCallStatus('🔊 재생 중...');
await speakLang(result.translated, result.targetLang, null);
}
} catch (e) {
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">오류: ${String(e.message || e).slice(0,120)}</div>`);
} finally {
if (callActive) beginCallListening();
}
}
// ═══════════════════════════════════════════════════════
// STATS
// ═══════════════════════════════════════════════════════