v3.0.8: 스킬앱 독립화 + 치과에이전트 + 음악/MCP/보이스 라우트 + DB 워크플로 개선

- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent
  각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거
- 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice
- skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입
- 세션 삭제 API: DELETE /api/admin/sessions/:username/:id
- Telegram defaultUsername: 어드민 계정 자동 추론
- Ollama /api/ollama/models에 vision 지원 여부 플래그 추가
- DB 워크플로 개선: workflow_images.py 대규모 리팩터링
- 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄)
- 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-21 17:07:58 +09:00
co-authored by Claude Sonnet 4.6
parent 0dae10693f
commit 9ad63ec8b1
131 changed files with 8980 additions and 3158 deletions
+41 -37
View File
@@ -305,8 +305,8 @@ export class BackgroundTaskRunner {
].filter(Boolean).join('\n');
}
private _restoreSessionForRetry(sessionId: string, resumeMessages: any[]): void {
clearHistory(sessionId);
private _restoreSessionForRetry(sessionId: string, resumeMessages: any[], username?: string): void {
clearHistory(sessionId, username);
for (const msg of resumeMessages) {
if (msg && (msg.role === 'user' || msg.role === 'assistant')) {
addMessage(sessionId, {
@@ -318,15 +318,15 @@ export class BackgroundTaskRunner {
disableCompactionCheck: true,
disableAutoSave: true,
maxMessages: BACKGROUND_SESSION_MAX_MESSAGES,
});
}, username);
}
}
}
private _persistResumeContextSnapshot(taskId: string, sessionId: string): void {
private _persistResumeContextSnapshot(taskId: string, sessionId: string, username?: string): void {
const task = loadTask(taskId);
const existingRound = Number(task?.resumeContext?.round) || 0;
const sessionHistory = getHistory(sessionId, 40);
const sessionHistory = getHistory(sessionId, 40, username);
updateResumeContext(taskId, {
messages: sessionHistory.slice(-MAX_RESUME_MESSAGES).map(h => ({
role: h.role,
@@ -450,7 +450,7 @@ export class BackgroundTaskRunner {
});
if (attempt < MAX_TRANSPORT_RETRIES) {
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)));
this._restoreSessionForRetry(sessionId, resumeMessages);
this._restoreSessionForRetry(sessionId, resumeMessages, task.ownerUsername);
continue;
}
return {
@@ -494,7 +494,7 @@ export class BackgroundTaskRunner {
content: `Retrying in ${retryResult.delayMs}ms (attempt ${retryResult.attemptsUsed}/${MAX_TRANSPORT_RETRIES + 1})`,
});
await new Promise(r => setTimeout(r, retryResult.delayMs || RETRY_DELAY_MS * (attempt + 1)));
this._restoreSessionForRetry(sessionId, resumeMessages);
this._restoreSessionForRetry(sessionId, resumeMessages, task.ownerUsername);
continue;
}
@@ -539,11 +539,12 @@ export class BackgroundTaskRunner {
if (!initialTask) return;
this._broadcast('task_running', { taskId, title: initialTask.title });
const ownerUsername = initialTask.ownerUsername;
// Keep session ID deterministic per task so resume restores the same context key.
// clearHistory() prevents stale cross-run contamination while preserving this mapping.
const sessionId = `task_${taskId}`;
clearHistory(sessionId);
clearHistory(sessionId, ownerUsername);
// Restore conversation context from prior runs.
const initialMessages = Array.isArray(initialTask.resumeContext?.messages)
@@ -561,7 +562,7 @@ export class BackgroundTaskRunner {
disableCompactionCheck: true,
disableAutoSave: true,
maxMessages: BACKGROUND_SESSION_MAX_MESSAGES,
});
}, ownerUsername);
}
}
appendJournal(taskId, {
@@ -694,7 +695,7 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'pause', content: pauseMsg });
this._broadcast('task_paused', { taskId, reason: pauseReason, scheduleId });
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
@@ -703,7 +704,7 @@ export class BackgroundTaskRunner {
if (task.status === 'waiting_subagent') {
activeRunners.delete(taskId);
appendJournal(taskId, { type: 'pause', content: 'Waiting for sub-agents to complete.' });
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
@@ -766,8 +767,8 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'status_push', content: 'Task complete. Re-synthesized and verified.' });
this._broadcast('task_complete', { taskId, summary: finalMsg });
await this._deliverToChannel(task, finalMsg);
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
@@ -777,8 +778,8 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'status_push', content: 'Task complete. Final synthesis verified.' });
this._broadcast('task_complete', { taskId, summary: deliverMsg });
await this._deliverToChannel(task, deliverMsg);
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
} else {
// Synthesis failed, use last known summary
@@ -787,8 +788,8 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'status_push', content: 'Task complete (synthesis round skipped).' });
this._broadcast('task_complete', { taskId, summary: fallbackSummary });
await this._deliverToChannel(task, fallbackSummary);
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
} else {
@@ -797,8 +798,8 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'status_push', content: 'Task complete: final summary already prepared.' });
this._broadcast('task_complete', { taskId, summary: task.finalSummary });
await this._deliverToChannel(task, task.finalSummary);
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
}
@@ -876,7 +877,7 @@ export class BackgroundTaskRunner {
const result = roundOutcome.result;
lastResultSummary = String(result.text || '').replace(/\s+/g, ' ').trim();
const sessionHistory = getHistory(sessionId, 40);
const sessionHistory = getHistory(sessionId, 40, ownerUsername);
updateResumeContext(taskId, {
messages: sessionHistory.slice(-MAX_RESUME_MESSAGES).map(h => ({
role: h.role,
@@ -885,7 +886,7 @@ export class BackgroundTaskRunner {
})),
round: (Number(task.resumeContext?.round) || 0) + 1,
});
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
if (pauseRequests.has(taskId)) {
const task = loadTask(taskId);
@@ -901,7 +902,7 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'pause', content: pauseMsg });
this._broadcast('task_paused', { taskId, reason: pauseReason, scheduleId });
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
@@ -911,8 +912,8 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'status_push', content: `Task complete: ${result.text.slice(0, 200)}` });
this._broadcast('task_complete', { taskId, summary: result.text });
await this._deliverToChannel(task, `Task complete: ${task.title}\n\n${result.text}`, { forceTelegram: true });
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
@@ -931,8 +932,8 @@ export class BackgroundTaskRunner {
});
this._broadcast('task_complete', { taskId, summary });
await this._deliverToChannel(freshForGoalCheck, `Task complete: ${freshForGoalCheck.title}\n\n${summary}`, { forceTelegram: true });
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
}
@@ -985,7 +986,7 @@ export class BackgroundTaskRunner {
`Self-heal attempts exceeded limit (${MAX_TOTAL_SELF_HEAL_ATTEMPTS}).`,
'Too many self-heal attempts without progress.',
);
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
const healed = await this._attemptSelfHeal(
@@ -994,16 +995,17 @@ export class BackgroundTaskRunner {
reason,
lastResultSummary,
sessionId,
ownerUsername,
);
if (healed === 'continue') { stepVerificationRetries.delete(stepIndex); continue; }
if (healed === 'complete') { flushSession(sessionId); return; }
if (healed === 'complete') { flushSession(sessionId, ownerUsername); return; }
// healed === 'escalate' — fall through to _pauseForAssistance
await this._pauseForAssistance(
task,
`Step ${stepIndex + 1} failed verification after ${retries} retries.`,
reason,
);
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
continue;
@@ -1035,7 +1037,7 @@ export class BackgroundTaskRunner {
`Self-heal attempts exceeded limit (${MAX_TOTAL_SELF_HEAL_ATTEMPTS}).`,
'Too many self-heal attempts without progress.',
);
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
const healed = await this._attemptSelfHeal(
@@ -1044,15 +1046,16 @@ export class BackgroundTaskRunner {
reason,
lastResultSummary,
sessionId,
ownerUsername,
);
if (healed === 'continue') { stepVerificationRetries.delete(stepIndex); continue; }
if (healed === 'complete') { flushSession(sessionId); return; }
if (healed === 'complete') { flushSession(sessionId, ownerUsername); return; }
await this._pauseForAssistance(
task,
`Step ${stepIndex + 1} failed verification after ${retries} retries.`,
reason,
);
flushSession(sessionId);
flushSession(sessionId, ownerUsername);
return;
}
continue;
@@ -1118,8 +1121,8 @@ export class BackgroundTaskRunner {
appendJournal(taskId, { type: 'status_push', content: `Task complete: ${result.text.slice(0, 200)}` });
this._broadcast('task_complete', { taskId, summary: result.text });
await this._deliverToChannel(task, `Task complete: ${task.title}\n\n${result.text}`, { forceTelegram: true });
this._persistResumeContextSnapshot(taskId, sessionId);
flushSession(sessionId);
this._persistResumeContextSnapshot(taskId, sessionId, ownerUsername);
flushSession(sessionId, ownerUsername);
return;
}
@@ -1155,6 +1158,7 @@ export class BackgroundTaskRunner {
detail: string,
lastResultText: string,
sessionId: string,
username?: string,
): Promise<'continue' | 'complete' | 'escalate'> {
const freshTask = loadTask(task.id);
if (!freshTask) return 'escalate';
@@ -1205,7 +1209,7 @@ export class BackgroundTaskRunner {
});
this._broadcast('task_complete', { taskId: task.id, summary: decision.message });
await this._deliverToChannel(freshTask, decision.message);
this._persistResumeContextSnapshot(task.id, sessionId);
this._persistResumeContextSnapshot(task.id, sessionId, username);
return 'complete';
}
@@ -1337,8 +1341,8 @@ export class BackgroundTaskRunner {
role: 'user',
content: `[BACKGROUND_TASK_RESULT task_id=${task.id}]`,
timestamp: Date.now() - 1,
});
addMessage(task.sessionId, { role: 'assistant', content: message, timestamp: Date.now() });
}, {}, task.ownerUsername);
addMessage(task.sessionId, { role: 'assistant', content: message, timestamp: Date.now() }, {}, task.ownerUsername);
} catch (e) {
console.warn('[BTR] Delivery failed (addMessage):', e);
}
+22 -3
View File
@@ -27,6 +27,11 @@ typedef uint8_t boolean;
#define INPUT_PULLUP 2
#define LED_BUILTIN 2
#define A0 36
#define A1 39
#define A2 34
#define A3 35
#define A4 32
#define A5 33
#define PI 3.14159265358979f
#define TWO_PI (PI*2)
#define LSBFIRST 0
@@ -192,9 +197,11 @@ static int _gpio_v[40]={};
inline void pinMode(uint8_t pin,uint8_t mode){printf("\\x02M%d:%c\\n",pin,mode==1?'O':'I');fflush(stdout);}
inline void digitalWrite(uint8_t pin,uint8_t val){_gpio_v[pin&39]=val;printf("\\x02G%d:%d\\n",pin,val);fflush(stdout);}
inline int digitalRead(uint8_t pin){return _gpio_v[pin&39];}
inline int analogRead(uint8_t){return 0;}
inline void analogWrite(uint8_t pin,int val){printf("\\x02G%d:%d\\n",pin,val);fflush(stdout);}
inline void analogReadResolution(int){}
static inline int _emb_adc_read(uint8_t pin){return (int)(_emb::_adc_v[pin&39]/3.3f*4095.0f+0.5f);}
#define analogRead(p) _emb_adc_read((uint8_t)(p))
static inline void _emb_adc_write(uint8_t pin,int val){printf("\\x02G%d:%d\\n",pin,val);fflush(stdout);}
#define analogWrite(p,v) _emb_adc_write((uint8_t)(p),(int)(v))
#define analogReadResolution(b) ((void)(b))
#define WL_CONNECTED 3
#define WL_DISCONNECTED 6
#define WL_CONNECT_FAILED 4
@@ -426,6 +433,8 @@ namespace _emb {
static int _ow_last_pin = 0;
// Motor globals: ENA,IN1,IN2,IN3,IN4,ENB
static int _motor_values[6] = {-1,-1,-1,-1,-1,-1};
// ADC globals — updated by host via \\x02ASET:<pin>:<voltage>\\n
static float _adc_v[40];
static String _dht_cmd_buf;
static bool _dht_in_cmd = false;
static void _dht_init_defaults() {
@@ -485,6 +494,12 @@ namespace _emb {
int p[7]; p[0]=cmd.indexOf(':');
for (int i=1;i<6;i++) p[i]=cmd.indexOf(':',p[i-1]+1);
for (int i=0;i<6;i++) _motor_values[i]=cmd.substring(p[i]+1,p[i+1]).toInt();
} else if (cmd.startsWith("ASET:")) {
// ASET:pin:voltage (0.00~3.30 V, ESP32 기본 기준전압 3.3V)
int p1=cmd.indexOf(':'), p2=cmd.indexOf(':',p1+1);
int pin=cmd.substring(p1+1,p2).toInt();
float v=cmd.substring(p2+1).toFloat();
if (pin>=0&&pin<40) _adc_v[pin]=v;
}
}
// Only consumes \\x02-prefixed commands; non-bridge bytes stay in Serial buffer
@@ -511,6 +526,10 @@ namespace _emb {
// delay() override: use inline function to avoid macro/ODR issues with Arduino SDK
static inline void _em_delay(unsigned long ms) { _emb::_dht_poll(); if(ms>0) vTaskDelay(pdMS_TO_TICKS(ms)); }
#define delay(ms) _em_delay(ms)
// analogRead override: QEMU has no real ADC hardware — read from _emb::_adc_v instead
static inline int _emb_adc_read(uint8_t pin) { return (int)(_emb::_adc_v[pin&39]/3.3f*4095.0f+0.5f); }
#define analogRead(p) _emb_adc_read((uint8_t)(p))
#define analogReadResolution(b) ((void)(b))
#endif
#endif
#endif
+9 -4
View File
@@ -125,6 +125,7 @@ namespace _emb8266 {
static bool _ow_init_done = false;
static int _ow_last_pin = 0;
static int _motor_values[6] = {-1,-1,-1,-1,-1,-1};
static float _adc_v = 0.0f;
static char _cmd_buf[96];
static int _cmd_len = 0;
static pthread_mutex_t _dht_mu = PTHREAD_MUTEX_INITIALIZER;
@@ -170,6 +171,9 @@ namespace _emb8266 {
&_motor_values[0], &_motor_values[1], &_motor_values[2],
&_motor_values[3], &_motor_values[4], &_motor_values[5]) == 6) {
// parsed
} else {
// ASET:voltage (0.00~3.30 V, ESP8266 단일 ADC)
float v; if (sscanf(cmd, "ASET:%f", &v) == 1) _adc_v = v;
}
pthread_mutex_unlock(&_dht_mu);
}
@@ -427,10 +431,11 @@ static inline void digitalWrite(uint8_t pin, uint8_t val) {
_emb8266::pin((int)pin, val?1:0);
}
static inline int digitalRead(uint8_t pin) { (void)pin; return 0; }
static inline int analogRead(uint8_t pin) { (void)pin; return 0; }
static inline void analogWrite(uint8_t pin, int val) { (void)pin; (void)val; }
static inline void analogWriteFreq(uint32_t freq) { (void)freq; }
static inline void analogWriteRange(uint32_t range) { (void)range; }
static inline int _emb_adc_read(uint8_t pin) { (void)pin; return (int)(_emb8266::_adc_v/3.3f*1023.0f+0.5f); }
#define analogRead(p) _emb_adc_read((uint8_t)(p))
#define analogWrite(p,v) ((void)(p),(void)(v))
#define analogWriteFreq(f) ((void)(f))
#define analogWriteRange(r) ((void)(r))
static inline void tone(uint8_t pin, unsigned int freq, unsigned long dur=0) { (void)pin;(void)freq;(void)dur; }
static inline void noTone(uint8_t pin) { (void)pin; }
static inline void shiftOut(uint8_t data, uint8_t clk, uint8_t order, uint8_t val) { (void)data;(void)clk;(void)order;(void)val; }
+47
View File
@@ -478,6 +478,27 @@ app.post('/api/spice/run', async (req, res) => {
}
});
// ── Compile cache (in-memory, max 30 entries, LRU) ────────────────────────────
type CompileCacheEntry = { result: any; ts: number };
const _compileCache = new Map<string, CompileCacheEntry>();
const _COMPILE_CACHE_MAX = 30;
function _compileCacheKey(code: string, fqbn: string) {
return require('crypto').createHash('sha256').update(code + '\x00' + fqbn).digest('hex');
}
function _compileCacheGet(key: string) {
const e = _compileCache.get(key);
if (!e) return null;
// Promote to most-recent (LRU)
_compileCache.delete(key); _compileCache.set(key, e);
return e.result;
}
function _compileCacheSet(key: string, result: any) {
if (_compileCache.size >= _COMPILE_CACHE_MAX) {
_compileCache.delete(_compileCache.keys().next().value!);
}
_compileCache.set(key, { result, ts: Date.now() });
}
app.post('/api/arduino/compile', async (req, res) => {
const sess = getSessionUser(req);
if (!sess) return res.status(401).json({ error: 'Not authenticated' });
@@ -512,6 +533,14 @@ app.post('/api/arduino/compile', async (req, res) => {
});
}
// ── Cache check ─────────────────────────────────────────────────────────────
const _cacheKey = _compileCacheKey(code, fqbn);
const _cached = _compileCacheGet(_cacheKey);
if (_cached && !_cached.error) {
console.log(`[arduino/compile] cache hit (${fqbn})`);
return res.json({ ..._cached, cached: true });
}
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'arduino-'));
const sketchName = 'sketch';
const sketchDir = path.join(tmpDir, sketchName);
@@ -626,6 +655,7 @@ app.post('/api/arduino/compile', async (req, res) => {
}
}
if (!result.error) _compileCacheSet(_cacheKey, result);
res.json(result);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -1243,6 +1273,15 @@ app.post('/api/arduino/qemu/:id/ds18b20', (req, res) => {
res.json({ ok: true });
});
app.post('/api/arduino/qemu/:id/adc', (req, res) => {
const s = qemuSessions.get(req.params.id);
if (!s) return res.status(404).json({ error: 'session not found' });
const { pin = 36, voltage = 0 } = req.body || {};
const pinN = Math.max(0, Math.min(39, parseInt(String(pin))));
s.pty.stdin.write(`\x02ASET:${pinN}:${Number(voltage).toFixed(3)}\n`);
res.json({ ok: true });
});
app.post('/api/arduino/qemu/:id/motor', (req, res) => {
const s = qemuSessions.get(req.params.id);
if (!s) return res.status(404).json({ error: 'session not found' });
@@ -1566,6 +1605,14 @@ app.post('/api/arduino/esp8266/:id/ds18b20', (req, res) => {
res.json({ ok: true });
});
app.post('/api/arduino/esp8266/:id/adc', (req, res) => {
const s = esp8266Sessions.get(req.params.id);
if (!s) return res.status(404).json({ error: 'session not found' });
const { voltage = 0 } = req.body || {};
try { s.proc.stdin.write(`\x02ASET:${Number(voltage).toFixed(3)}\n`); } catch {}
res.json({ ok: true });
});
app.post('/api/arduino/esp8266/:id/motor', (req, res) => {
const s = esp8266Sessions.get(req.params.id);
if (!s) return res.status(404).json({ error: 'session not found' });
+344
View File
@@ -0,0 +1,344 @@
import express from 'express';
import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const SCRIPT_DIR = path.resolve(__dirname, '../../.smallclaw/databases');
const DB_PATH = path.join(SCRIPT_DIR, 'dental_dict.db');
const IMG_BASE = path.join(SCRIPT_DIR, 'dental_images');
interface Job {
proc: ChildProcess;
logs: string[];
status: 'running' | 'done' | 'error';
source: string;
startedAt: number;
baseMaxId?: number; // image ID before job started
}
let activeJob: Job | null = null; // 다운로드 작업
let activeVerify: Job | null = null; // 검증 작업 (독립 실행)
const sseClients: express.Response[] = [];
function broadcast(data: object) {
const msg = `data: ${JSON.stringify(data)}\n\n`;
for (let i = sseClients.length - 1; i >= 0; i--) {
try { sseClients[i].write(msg); }
catch { sseClients.splice(i, 1); }
}
}
export function registerDentalRoutes(app: express.Application): void {
// GET /api/dental/stats
app.get('/api/dental/stats', (req, res) => {
try {
const db = new Database(DB_PATH, { readonly: true });
const total = (db.prepare('SELECT COUNT(*) as n FROM terms').get() as any).n;
const withImg = (db.prepare("SELECT COUNT(*) as n FROM terms WHERE image_url IS NOT NULL AND image_url != ''").get() as any).n;
const byCat = db.prepare(`
SELECT category,
COUNT(*) as total,
SUM(CASE WHEN image_url IS NOT NULL AND image_url != '' THEN 1 ELSE 0 END) as has_img
FROM terms
GROUP BY category
ORDER BY total DESC
`).all();
db.close();
res.json({ total, with_image: withImg, without_image: total - withImg, by_category: byCat });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// GET /api/dental/recent?limit=N&category=카테고리&since=<ms>
// No category → image_added_at DESC (모든 소스 통합)
// With category → filter by category, sort by image_added_at DESC
app.get('/api/dental/recent', (req, res) => {
try {
const db = new Database(DB_PATH, { readonly: true });
const limit = Math.min(Number(req.query.limit) || 24, 200);
const category = req.query.category ? String(req.query.category) : null;
const since = req.query.since ? Number(req.query.since) : 0;
if (category) {
// image_url 중복 제거: 같은 사진은 한 번만 표시
const rows = db.prepare(`
SELECT id, korean, english, category, image_url, MAX(image_added_at) AS image_added_at
FROM terms
WHERE image_url IS NOT NULL AND image_url != ''
AND category = ?
GROUP BY image_url
ORDER BY image_added_at DESC, id ASC LIMIT ?
`).all(category, limit) as any[];
db.close();
return res.json(rows);
}
// 전체 최신 이미지: image_url 중복 제거 + image_added_at 기준
const rows = db.prepare(`
SELECT id, korean, english, category, image_url, MAX(image_added_at) AS image_added_at
FROM terms
WHERE image_url IS NOT NULL AND image_url != ''
${since ? 'AND image_added_at > ?' : ''}
GROUP BY image_url
ORDER BY image_added_at DESC LIMIT ?
`).all(...(since ? [since, limit] : [limit])) as any[];
db.close();
res.json(rows);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// GET /api/dental/status
app.get('/api/dental/status', (req, res) => {
res.json({
running: activeJob?.status === 'running',
status: activeJob?.status || 'idle',
source: activeJob?.source || null,
startedAt: activeJob?.startedAt || null,
logCount: activeJob?.logs.length || 0,
});
});
// POST /api/dental/start
app.post('/api/dental/start', (req, res) => {
if (activeJob?.status === 'running') {
return res.status(409).json({ ok: false, error: '이미 실행 중' });
}
const VALID_SOURCES = new Set([
'wikimedia','openi','figshare','pmc',
'kaggle','roboflow','huggingface','radiopaedia','itu',
'roboflow_api','zenodo','mendeley',
// 신규 (2026-06): 웹 스크랩/API + 로컬 다운로드 데이터셋
'vcu','oralsd','akudental','code','alphadent','brar','oopid','sts3d',
// 스톡 이미지 API
'stock',
]);
// sources: array (multi-select) or legacy source: string
const rawSources: string[] = Array.isArray(req.body?.sources)
? req.body.sources
: [String(req.body?.source || req.body?.sources || 'wikimedia')];
const sources = rawSources.filter(s => VALID_SOURCES.has(s));
if (!sources.length) {
return res.status(400).json({ ok: false, error: `소스 오류. 허용: ${[...VALID_SOURCES].join(', ')}` });
}
const limit = req.body?.limit ? Number(req.body.limit) : undefined;
const category = req.body?.category ? String(req.body.category) : undefined;
const model = req.body?.model ? String(req.body.model) : undefined;
const noVerify = req.body?.noVerify === true;
const codeLlmMatch = req.body?.codeLlmMatch === true;
// 소스별 인수 매핑 (인터넷 소스만 limit/category/model 적용)
const maxFlag: Record<string, string> = {
wikimedia: '--max-wikimedia', openi: '--max-openi',
figshare: '--max-figshare', pmc: '--max-pmc',
vcu: '--max-vcu', oralsd: '--max-oralsd', akudental: '--max-akudental',
code: '--max-code',
stock: '--max-stock',
};
const catFlag: Record<string, string> = {
wikimedia: '--wikimedia-category', openi: '--openi-category',
figshare: '--figshare-category', pmc: '--pmc-category',
vcu: '--vcu-category', oralsd: '--oralsd-category', akudental: '--akudental-category',
code: '--code-category',
stock: '--stock-category',
};
const modelFlag: Record<string, string> = {
wikimedia: '--wikimedia-model', openi: '--openi-model',
figshare: '--figshare-model', pmc: '--pmc-model',
vcu: '--vcu-model', oralsd: '--oralsd-model', akudental: '--akudental-model',
stock: '--stock-model',
};
// 다중 소스: 쉼표 구분 문자열로 manage.py에 전달
const sourceStr = sources.join(',');
const scriptArgs = ['manage.py', 'add-images', '--source', sourceStr];
if (noVerify) scriptArgs.push('--no-verify');
if (codeLlmMatch && sources.includes('code')) scriptArgs.push('--code-llm-match');
for (const src of sources) {
if (limit && maxFlag[src]) scriptArgs.push(maxFlag[src], String(limit));
if (category && catFlag[src]) scriptArgs.push(catFlag[src], category);
if (model && modelFlag[src]) scriptArgs.push(modelFlag[src], model);
}
// Record max image ID before job so we can show only new images after
let baseMaxId = 0;
try {
const db = new Database(DB_PATH, { readonly: true });
const row = db.prepare("SELECT MAX(id) as m FROM terms WHERE image_url IS NOT NULL AND image_url != ''").get() as any;
baseMaxId = row?.m || 0;
db.close();
} catch {}
const proc = spawn('python3', scriptArgs, {
cwd: SCRIPT_DIR,
env: { ...process.env, PYTHONUNBUFFERED: '1' },
});
activeJob = { proc, logs: [], status: 'running', source: sourceStr, startedAt: Date.now(), baseMaxId };
proc.stdout.on('data', (d: Buffer) => {
const text = d.toString();
activeJob!.logs.push(text);
broadcast({ type: 'log', text });
});
proc.stderr.on('data', (d: Buffer) => {
const text = '[ERR] ' + d.toString();
activeJob!.logs.push(text);
broadcast({ type: 'log', text, isErr: true });
});
proc.on('close', (code: number | null) => {
if (activeJob) activeJob.status = code === 0 ? 'done' : 'error';
broadcast({ type: 'done', code });
});
res.json({ ok: true, sources, limit, category });
});
// POST /api/dental/stop
app.post('/api/dental/stop', (req, res) => {
if (activeJob?.proc && activeJob.status === 'running') {
activeJob.proc.kill('SIGTERM');
activeJob.status = 'done';
broadcast({ type: 'done', code: -1, reason: 'user_stop' });
}
res.json({ ok: true });
});
// GET /api/dental/logs — buffered logs
app.get('/api/dental/logs', (req, res) => {
res.json({ logs: activeJob?.logs || [], status: activeJob?.status || 'idle' });
});
// GET /api/dental/verify-logs — verify job logs
app.get('/api/dental/verify-logs', (req, res) => {
res.json({ logs: activeVerify?.logs || [], status: activeVerify?.status || 'idle' });
});
// POST /api/dental/verify-stop
app.post('/api/dental/verify-stop', (req, res) => {
if (activeVerify?.proc && activeVerify.status === 'running') {
activeVerify.proc.kill('SIGTERM');
activeVerify.status = 'done';
broadcast({ type: 'verify_done', code: -1, reason: 'user_stop' });
}
res.json({ ok: true });
});
// POST /api/dental/verify — run workflow_verify (independent of download job)
app.post('/api/dental/verify', (req, res) => {
if (activeVerify?.status === 'running') {
return res.status(409).json({ ok: false, error: '검증 작업 이미 실행 중' });
}
const limit = req.body?.limit ? Number(req.body.limit) : 20;
const fix = req.body?.fix === true;
const category = req.body?.category ? String(req.body.category) : undefined;
const model = req.body?.model ? String(req.body.model) : undefined;
const scriptArgs = ['manage.py', 'verify', '--limit', String(limit)];
if (fix) scriptArgs.push('--fix');
if (category) scriptArgs.push('--category', category);
if (model) scriptArgs.push('--model', model);
const proc = spawn('python3', scriptArgs, {
cwd: SCRIPT_DIR,
env: { ...process.env, PYTHONUNBUFFERED: '1' },
});
activeVerify = { proc, logs: [], status: 'running', source: 'verify', startedAt: Date.now() };
proc.stdout.on('data', (d: Buffer) => {
const text = d.toString();
activeVerify!.logs.push(text);
broadcast({ type: 'verify_log', text });
});
proc.stderr.on('data', (d: Buffer) => {
const text = '[ERR] ' + d.toString();
activeVerify!.logs.push(text);
broadcast({ type: 'verify_log', text, isErr: true });
});
proc.on('close', (code: number | null) => {
if (activeVerify) activeVerify.status = code === 0 ? 'done' : 'error';
broadcast({ type: 'verify_done', code });
});
res.json({ ok: true, limit, fix, category, model });
});
// GET /api/dental/search — full-text search in dental_dict
app.get('/api/dental/search', (req, res) => {
const q = String(req.query.q || '').trim();
const limit = Math.min(Number(req.query.limit) || 30, 100);
if (!q) return res.json([]);
try {
const db = new Database(DB_PATH, { readonly: true });
// FTS first, then fallback LIKE
let rows: any[];
try {
rows = db.prepare(`
SELECT t.id, t.korean, t.english, t.category, t.definition, t.image_url
FROM terms_fts f
JOIN terms t ON t.id = f.rowid
WHERE terms_fts MATCH ?
ORDER BY rank
LIMIT ?
`).all(q + '*', limit);
} catch {
rows = db.prepare(`
SELECT id, korean, english, category, definition, image_url
FROM terms
WHERE korean LIKE ? OR english LIKE ? OR definition LIKE ?
LIMIT ?
`).all(`%${q}%`, `%${q}%`, `%${q}%`, limit);
}
db.close();
res.json(rows);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// GET /api/dental/events — SSE stream
app.get('/api/dental/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// Replay buffered download logs
if (activeJob?.logs) {
for (const text of activeJob.logs) {
res.write(`data: ${JSON.stringify({ type: 'log', text })}\n\n`);
}
if (activeJob.status !== 'running') {
res.write(`data: ${JSON.stringify({ type: 'done', code: 0 })}\n\n`);
}
}
// Replay buffered verify logs
if (activeVerify?.logs) {
for (const text of activeVerify.logs) {
res.write(`data: ${JSON.stringify({ type: 'verify_log', text })}\n\n`);
}
if (activeVerify.status !== 'running') {
res.write(`data: ${JSON.stringify({ type: 'verify_done', code: 0 })}\n\n`);
}
}
sseClients.push(res);
const heartbeat = setInterval(() => {
try { res.write(': ping\n\n'); } catch { clearInterval(heartbeat); }
}, 15000);
req.on('close', () => {
clearInterval(heartbeat);
const i = sseClients.indexOf(res);
if (i >= 0) sseClients.splice(i, 1);
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import express from 'express';
import { getMCPManager } from './mcp-manager';
export function registerMCPRoutes(app: express.Application): void {
app.get('/api/mcp/servers', (_req, res) => {
try {
const mgr = getMCPManager();
const configs = mgr.getConfigs();
const status = mgr.getStatus();
const merged = configs.map(cfg => {
const s = status.find(x => x.id === cfg.id);
return { ...cfg, status: s?.status || 'disconnected', toolCount: s?.tools || 0, toolNames: s?.toolNames || [], error: s?.error };
});
res.json({ success: true, servers: merged });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/api/mcp/servers', (req, res) => {
try {
const mgr = getMCPManager();
const cfg = req.body;
if (!cfg.id || !cfg.name) { res.status(400).json({ success: false, error: 'id and name are required' }); return; }
if (!cfg.id.match(/^[a-z0-9_-]+$/i)) { res.status(400).json({ success: false, error: 'id must be alphanumeric/underscore/dash only' }); return; }
mgr.upsertConfig(cfg);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.delete('/api/mcp/servers/:id', (req, res) => {
try {
const mgr = getMCPManager();
const deleted = mgr.deleteConfig(req.params.id);
res.json({ success: deleted, error: deleted ? undefined : 'Server not found' });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/api/mcp/servers/:id/connect', async (req, res) => {
try {
const mgr = getMCPManager();
const result = await mgr.connect(req.params.id);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/api/mcp/servers/:id/disconnect', async (req, res) => {
try {
const mgr = getMCPManager();
await mgr.disconnect(req.params.id);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get('/api/mcp/tools', (_req, res) => {
try {
const mgr = getMCPManager();
res.json({ success: true, tools: mgr.getAllTools() });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
} // registerMCPRoutes
+277
View File
@@ -0,0 +1,277 @@
import express from 'express';
import path from 'path';
import fs from 'fs';
import { renderMusic, renderMidi, getRenderFile, getRenderPageFile, renderMidiAsync, saveMidiFile } from './music-renderer';
export function registerMusicRoutes(app: express.Application): void {
app.post('/api/music/render', (req, res) => {
try {
const source = String(req.body?.source || '').trim();
if (!source) { res.status(400).json({ error: 'Missing source' }); return; }
const result = renderMusic(source);
// Save MIDI to workspace/music/<title>/ when user has a workspace
let savedTo: string | null = null;
const renderUser = (req as any).user;
if (result.midiPath && renderUser?.workspace) {
const titleMatch = source.match(/^T:\s*(.+)$/m);
const title = (titleMatch?.[1] || 'untitled')
.replace(/[/\\:*?"<>|]/g, '_').trim() || 'untitled';
const musicDir = path.join(renderUser.workspace, 'music', title);
fs.mkdirSync(musicDir, { recursive: true });
const destPath = path.join(musicDir, title + '.mid');
fs.copyFileSync(result.midiPath, destPath);
savedTo = destPath;
}
res.json({
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: result.midiPath ? `/api/music/file/${result.id}/midi` : null,
saved_to: savedTo,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/music/midi — upload MIDI file → save to workspace + render dir, return action picker data
app.post('/api/music/midi', (req, res) => {
const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) {
res.status(400).json({ error: 'Content-Type must be multipart/form-data' }); return;
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ error: 'Missing boundary' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('binary');
const parts = raw.split('--' + boundary);
let fileData: Buffer | null = null;
let originalFilename = 'upload.midi';
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"') && !header.includes('name="midi"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/i);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
originalFilename = raw8;
}
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ error: 'No MIDI file found' }); return; }
if (fileData.length > 10 * 1024 * 1024) { res.status(400).json({ error: 'File too large (max 10MB)' }); return; }
// Save to render dir (original.midi) — no LilyPond yet
const info = saveMidiFile(fileData);
// Save to workspace/music/<songName>/
let savedTo: string | null = null;
const uploadUser = (req as any).user;
if (uploadUser?.workspace) {
const songName = originalFilename
.replace(/\.midi?$/i, '')
.replace(/[/\\:*?"<>|]/g, '_')
.trim() || 'untitled';
const musicDir = path.join(uploadUser.workspace, 'music', songName);
fs.mkdirSync(musicDir, { recursive: true });
const safeName = originalFilename.replace(/[/\\:*?"<>|]/g, '_');
const destPath = path.join(musicDir, safeName);
if (!fs.existsSync(destPath)) fs.writeFileSync(destPath, fileData);
savedTo = destPath;
}
res.json({
id: info.id,
filename: originalFilename,
midi_url: info.midiUrl,
has_svg: info.hasSvg,
page_count: info.pageCount,
svg_url: info.hasSvg ? `/api/music/file/${info.id}/svg` : null,
pdf_url: info.pdfUrl,
saved_to: savedTo,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
});
// POST /api/music/midi-from-path — internal-only: register an already-saved MIDI file by path
app.post('/api/music/midi-from-path', (req, res) => {
const ip = req.socket.remoteAddress || '';
if (!ip.includes('127.0.0.1') && !ip.includes('::1') && !ip.includes('::ffff:127.0.0.1')) {
res.status(403).json({ error: 'Internal endpoint — localhost only' }); return;
}
try {
const filePath = String(req.body?.path || '').trim();
const filename = String(req.body?.filename || path.basename(filePath));
if (!filePath) { res.status(400).json({ error: 'Missing path' }); return; }
if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'File not found: ' + filePath }); return; }
const fileData = fs.readFileSync(filePath);
const info = saveMidiFile(fileData);
res.json({
id: info.id,
filename,
midi_url: info.midiUrl,
has_svg: info.hasSvg,
page_count: info.pageCount,
svg_url: info.hasSvg ? `/api/music/file/${info.id}/svg` : null,
pdf_url: info.pdfUrl,
saved_to: filePath,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/music/midi-score — on-demand sheet music render for uploaded MIDI
app.post('/api/music/midi-score', (req, res) => {
const { id } = req.body as { id: string };
if (!id || !id.startsWith('midi-')) { res.status(400).json({ error: 'Invalid id' }); return; }
const origPath = getRenderFile(id, 'midi');
if (!origPath) { res.status(404).json({ error: 'MIDI not found — 파일을 다시 업로드하세요' }); return; }
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20_000);
const send = (data: object) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}; };
const finish = () => { clearInterval(hb); try { res.end(); } catch {} };
renderMidiAsync(
origPath,
(msg) => send({ type: 'progress', message: msg }),
(result) => {
send({
type: 'done',
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: `/api/music/file/${result.id}/midi`,
});
finish();
},
(err) => { send({ type: 'error', message: err.message }); finish(); },
);
});
// GET /api/music/file/:id/svg — serve page 1 SVG
app.get('/api/music/file/:id/svg', (req, res) => {
const filePath = getRenderFile(req.params.id, 'svg');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'image/svg+xml');
res.sendFile(filePath);
});
// GET /api/music/file/:id/svg/:page — serve specific page (1-indexed)
app.get('/api/music/file/:id/svg/:page', (req, res) => {
const page = parseInt(req.params.page, 10);
const filePath = isNaN(page) ? null : getRenderPageFile(req.params.id, page);
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'image/svg+xml');
res.sendFile(filePath);
});
// GET /api/music/file/:id/pdf — serve rendered PDF
app.get('/api/music/file/:id/pdf', (req, res) => {
const filePath = getRenderFile(req.params.id, 'pdf');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="score.pdf"');
res.sendFile(filePath);
});
// GET /api/music/file/:id/midi — serve rendered MIDI
app.get('/api/music/file/:id/midi', (req, res) => {
const filePath = getRenderFile(req.params.id, 'midi');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'audio/midi');
res.sendFile(filePath);
});
// GET /api/music/list — list MIDI files from user's workspace/music/
app.get('/api/music/list', (req, res) => {
const user = (req as any).user;
const workspacePath = user?.workspace;
if (!workspacePath) { res.json({ files: [] }); return; }
const musicDir = path.join(workspacePath, 'music');
try {
if (!fs.existsSync(musicDir)) { res.json({ files: [] }); return; }
const entries: { name: string; path: string; mtime: number }[] = [];
const dirs = fs.readdirSync(musicDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const subDir = path.join(musicDir, d.name);
const midFiles = fs.readdirSync(subDir).filter(f => /\.(mid|midi)$/i.test(f));
for (const mf of midFiles) {
const fp = path.join(subDir, mf);
const st = fs.statSync(fp);
entries.push({ name: d.name, path: `/api/music/workspace-file/${encodeURIComponent(d.name)}/${encodeURIComponent(mf)}`, mtime: st.mtimeMs });
}
}
entries.sort((a, b) => b.mtime - a.mtime);
res.json({ files: entries });
} catch (e: any) {
res.json({ files: [] });
}
});
// GET /api/music/workspace-file/:dir/:file — serve MIDI from user workspace
app.get('/api/music/workspace-file/:dir/:file', (req, res) => {
const user = (req as any).user;
const workspacePath = user?.workspace;
if (!workspacePath) { res.status(403).json({ error: 'No workspace' }); return; }
const filePath = path.join(workspacePath, 'music', req.params.dir, req.params.file);
if (!filePath.startsWith(path.join(workspacePath, 'music'))) {
res.status(403).json({ error: 'Forbidden' }); return;
}
if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'Not found' }); return; }
const safeName = Buffer.from(req.params.file).toString('ascii').replace(/[^\x20-\x7E]/g, '_');
res.setHeader('Content-Type', 'audio/midi');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(req.params.file)}`);
const stream = fs.createReadStream(filePath);
stream.on('error', (e) => { if (!res.headersSent) res.status(500).json({ error: e.message }); });
stream.pipe(res);
});
// DELETE /api/music/workspace-file/:dir — delete MIDI folder from user workspace
app.delete('/api/music/workspace-file/:dir', (req, res) => {
const user = (req as any).user;
const workspacePath = user?.workspace;
if (!workspacePath) { res.status(403).json({ error: 'No workspace' }); return; }
const dirPath = path.join(workspacePath, 'music', req.params.dir);
if (!dirPath.startsWith(path.join(workspacePath, 'music') + path.sep)) {
res.status(403).json({ error: 'Forbidden' }); return;
}
if (!fs.existsSync(dirPath)) { res.status(404).json({ error: 'Not found' }); return; }
try {
fs.rmSync(dirPath, { recursive: true, force: true });
res.json({ success: true });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
} // registerMusicRoutes
+83
View File
@@ -0,0 +1,83 @@
import express from 'express';
import path from 'path';
import fs from 'fs';
import { transcribeAudio } from '../tools/stt.js';
import { getConfig } from '../config/config';
export function registerVoiceRoutes(app: express.Application): void {
app.post('/api/voice/stt', async (req: express.Request, res: express.Response) => {
const contentType = String(req.headers['content-type'] || 'audio/webm');
const fmt = contentType.includes('ogg') ? 'ogg'
: contentType.includes('wav') ? 'wav'
: contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a'
: 'webm';
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', async () => {
const buf = Buffer.concat(chunks);
if (!buf.length) { res.status(400).json({ success: false, error: 'No audio data' }); return; }
try {
const result = await transcribeAudio(buf, fmt);
res.json({ success: true, text: result.text.trim(), language: result.language });
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
req.on('error', (e: any) => res.status(500).json({ success: false, error: String(e?.message || e) }));
});
// ── Voice: TTS (Piper → WAV) ─────────────────────────────────────────────
app.post('/api/voice/tts', async (req: express.Request, res: express.Response) => {
const { text } = req.body || {};
if (!text || typeof text !== 'string') { res.status(400).json({ success: false, error: 'text required' }); return; }
const cfg = (getConfig().getConfig() as any)?.voice;
if (!cfg?.tts) { res.status(503).json({ success: false, error: 'TTS not configured' }); return; }
const provider = cfg.tts.provider || 'piper';
try {
const os = await import('os');
const { spawn: spawnProc } = await import('child_process');
if (provider === 'edge_tts') {
const voice = cfg.tts.voice || 'ko-KR-SunHiNeural';
const mp3Path = path.join(os.tmpdir(), `tts_${Date.now()}.mp3`);
const scriptPath = path.join(__dirname, '../../src/tools/edge_tts_synth.py');
await new Promise<void>((resolve, reject) => {
const py = spawnProc('python3', [scriptPath, voice, mp3Path]);
py.stdin.write(text.slice(0, 2000), 'utf8');
py.stdin.end();
let stderr = '';
py.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8'); });
py.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`)));
py.on('error', reject);
});
const mp3 = fs.readFileSync(mp3Path);
try { fs.unlinkSync(mp3Path); } catch {}
res.set('Content-Type', 'audio/mpeg');
res.send(mp3);
} else {
// piper
const piperPath = cfg.tts.piperPath || 'piper';
const modelPath = cfg.tts.modelPath || '';
const configPath = cfg.tts.configPath || '';
if (!modelPath) { res.status(503).json({ success: false, error: 'TTS model path not configured' }); return; }
const wavPath = path.join(os.tmpdir(), `tts_${Date.now()}.wav`);
const piperArgs = ['--model', modelPath, '--output_file', wavPath];
if (configPath) piperArgs.push('--config', configPath);
await new Promise<void>((resolve, reject) => {
const piper = spawnProc(piperPath, piperArgs);
piper.stdin.write(text.slice(0, 2000), 'utf8');
piper.stdin.end();
piper.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`Piper exited ${code}`)));
piper.on('error', reject);
});
const wav = fs.readFileSync(wavPath);
try { fs.unlinkSync(wavPath); } catch {}
res.set('Content-Type', 'audio/wav');
res.send(wav);
}
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
} // registerVoiceRoutes
+157 -10
View File
@@ -22,6 +22,7 @@ import { registerVoiceRoutes } from './routes-voice';
import { registerMusicRoutes } from './routes-music';
import { registerMCPRoutes } from './routes-mcp';
import { registerLanguageRoutes } from './routes-language';
import { registerDentalRoutes } from './routes-dental';
import {
getConfig,
getAgents,
@@ -888,6 +889,14 @@ const telegramChannel = new TelegramChannel(
getIsModelBusy: () => checkIsModelBusy(),
broadcast: broadcastWS,
resolveUsername: (telegramUserId: string) => resolveUsernameByTelegramId(telegramUserId) || undefined,
defaultUsername: () => {
const users = listUsers();
for (const u of users) {
const roleEntry = getUserField(u, 'role');
if (roleEntry && roleEntry.expose() === 'admin') return u;
}
return users[0];
},
}
);
@@ -9321,6 +9330,23 @@ app.get('/api/admin/context-viewer', async (req, res) => {
res.json({ sessions: withOnline });
});
app.delete('/api/admin/sessions/:username/:id', async (req, res) => {
const session = getSessionUser(req);
if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' });
const { username, id } = req.params;
const configDir = getConfig().getConfigDir();
const sessDir = username === 'legacy'
? path.join(configDir, 'sessions')
: path.join(configDir, 'users', username, 'sessions');
const filePath = path.join(sessDir, `${id}.json`);
try {
await fsp.unlink(filePath);
res.json({ success: true });
} catch (e: any) {
res.status(404).json({ success: false, error: String(e?.message || e) });
}
});
app.use('/vendor', express.static(path.join(webUiPath, 'vendor'), {
setHeaders: (res, filePath) => {
res.setHeader('Cache-Control', 'no-cache');
@@ -9895,7 +9921,7 @@ app.get('/api/status', async (_req, res) => {
});
app.post('/api/chat', async (req, res) => {
const { message, sessionId = 'default', pinnedMessages, model: reqModel, direct, history: clientHistory, codeAiLocal, useTools, boardContext } = req.body;
const { message, sessionId = 'default', pinnedMessages, model: reqModel, direct, history: clientHistory, codeAiLocal, useTools, boardContext, skillContext } = req.body;
if (!message || typeof message !== 'string') { res.status(400).json({ error: 'Message required' }); return; }
const user = (req as any).user;
@@ -10026,7 +10052,7 @@ app.post('/api/chat', async (req, res) => {
? `${user?.username || 'anon'}@aicoder${_codeAiModelShort ? '-' + _codeAiModelShort : ''}`
: (user?.username ? `${user.username}@${sessionSuffix}` : `(anon)@${sessionSuffix}`);
console.log(`\n[v2] USER [${userTag}]: ${message}`);
const callerCtx = direct ? 'no_preflight' : undefined;
const callerCtx = direct ? 'no_preflight' : (skillContext ? String(skillContext).slice(0, 8000) : undefined);
const isCodeEditorSession = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId));
const histOverride = (isCodeEditorSession && Array.isArray(clientHistory)) ? clientHistory : undefined;
// Code editor chat runs on its own clean tool loop — no multi-agent orchestration.
@@ -11800,21 +11826,39 @@ app.get('/api/credentials/audit', (_req, res) => {
res.json({ success: false, lines: [], error: err.message });
}
});
// Fetch available Ollama models (proxies Ollama /api/tags)
// Fetch available Ollama models (proxies Ollama /api/tags), with vision capability flag
app.get('/api/ollama/models', async (_req, res) => {
try {
const ollamaEndpoint = (getConfig().getConfig() as any).ollama?.endpoint || 'http://localhost:11434';
const response = await fetch(`${ollamaEndpoint}/api/tags`);
if (!response.ok) { res.json({ success: false, models: [], error: `Ollama returned ${response.status}` }); return; }
const data = await response.json() as any;
const models = (data.models || []).map((m: any) => ({
name: m.name,
size: m.size,
parameter_size: m.details?.parameter_size || '',
family: m.details?.family || '',
modified_at: m.modified_at,
const rawModels: any[] = data.models || [];
// Fetch capabilities for each model in parallel (timeout 3s each)
const withCaps = await Promise.all(rawModels.map(async (m: any) => {
let vision = false;
try {
const r = await fetch(`${ollamaEndpoint}/api/show`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: m.name }),
signal: AbortSignal.timeout(3000),
});
if (r.ok) {
const d = await r.json() as any;
vision = Array.isArray(d.capabilities) && d.capabilities.includes('vision');
}
} catch {}
return {
name: m.name,
size: m.size,
parameter_size: m.details?.parameter_size || '',
family: m.details?.family || '',
modified_at: m.modified_at,
vision,
};
}));
res.json({ success: true, models });
res.json({ success: true, models: withCaps });
} catch (err: any) {
res.json({ success: false, models: [], error: err.message });
}
@@ -11915,6 +11959,45 @@ app.get('/api/logs', (req, res) => {
res.json({ entries: logRing.slice(), totalCount: logTotalCount });
});
// ── Network speed tracker ──────────────────────────────────────────────────
let _netPrev: { rx: number; tx: number; ts: number } | null = null;
let _netSpeed = { rx_bps: 0, tx_bps: 0, iface: '' };
function readNetDev(): { rx: number; tx: number; iface: string } | null {
try {
const lines = fs.readFileSync('/proc/net/dev', 'utf8').split('\n');
let rx = 0, tx = 0, iface = '';
for (const line of lines) {
const m = line.trim().match(/^(\S+?):\s+(\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+(\d+)/);
if (!m) continue;
const name = m[1];
if (name === 'lo' || name.startsWith('docker') || name.startsWith('veth') || name.startsWith('br-')) continue;
rx += Number(m[2]); tx += Number(m[3]); iface = name;
}
return { rx, tx, iface };
} catch { return null; }
}
function updateNetSpeed() {
const cur = readNetDev();
if (!cur) return;
const now = Date.now();
if (_netPrev) {
const dt = (now - _netPrev.ts) / 1000;
if (dt > 0) {
_netSpeed = {
rx_bps: Math.max(0, (cur.rx - _netPrev.rx) / dt),
tx_bps: Math.max(0, (cur.tx - _netPrev.tx) / dt),
iface: cur.iface,
};
}
}
_netPrev = { rx: cur.rx, tx: cur.tx, ts: now };
}
updateNetSpeed();
setInterval(updateNetSpeed, 2000);
// ──────────────────────────────────────────────────────────────────────────
app.get('/api/system-stats', async (req, res) => {
const totalMem = osModule.totalmem();
const freeMem = osModule.freemem();
@@ -11981,6 +12064,7 @@ app.get('/api/system-stats', async (req, res) => {
memory_total_gb: totalMem / (1024 ** 3),
},
gpu: gpuStats,
network: _netSpeed,
ollama_process: { running: ollamaRunning, process_count: ollamaCount, total_memory_mb: ollamaMemMb },
gateway_process: { rss_mb: rss / (1024 * 1024) },
active_provider: (getConfig().getConfig() as any).llm?.provider || 'ollama',
@@ -12438,6 +12522,69 @@ app.post('/api/models/test', async (req, res) => {
registerMusicRoutes(app);
registerLanguageRoutes(app);
registerDentalRoutes(app);
// GET /api/excel/list — list xlsx files from user workspace
app.get('/api/excel/list', (req, res) => {
const user = (req as any).user;
const workspacePath = user?.workspace;
if (!workspacePath) { res.json({ files: [] }); return; }
const results: { name: string; relPath: string; apiPath: string; mtime: number }[] = [];
const walk = (dir: string, depth: number) => {
if (depth > 3) return;
try {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) { walk(full, depth + 1); }
else if (/\.(xlsx|xls)$/i.test(ent.name)) {
const rel = path.relative(workspacePath, full);
results.push({ name: ent.name, relPath: rel, apiPath: `/api/files/${rel}`, mtime: fs.statSync(full).mtimeMs });
}
}
} catch {}
};
walk(workspacePath, 0);
results.sort((a, b) => b.mtime - a.mtime);
res.json({ files: results });
});
// GET /api/legal/search — proxy to korean_law_search / us_case_search tools
app.get('/api/legal/search', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) { res.status(400).json({ error: 'q is required' }); return; }
const target = String(req.query.target || 'law');
const sort = String(req.query.sort || 'rel');
const n = Math.min(parseInt(String(req.query.n || '10'), 10), 20);
const lang = String(req.query.lang || 'ko'); // 'ko' or 'us'
try {
if (lang === 'us') {
const { usCaseSearchTool } = await import('../tools/legal.js');
const result = await usCaseSearchTool.execute({ query: q, max_results: n });
res.json(result);
} else {
const { koreanLawSearchTool } = await import('../tools/legal.js');
const result = await koreanLawSearchTool.execute({ query: q, target, sort, max_results: n });
res.json(result);
}
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// GET /api/legal/fetch — proxy to korean_law_fetch tool
app.get('/api/legal/fetch', async (req, res) => {
const id = String(req.query.id || '').trim();
const target = String(req.query.target || 'law');
const article = String(req.query.article || '').trim();
if (!id) { res.status(400).json({ error: 'id is required' }); return; }
try {
const { koreanLawFetchTool } = await import('../tools/legal.js');
const result = await koreanLawFetchTool.execute({ law_id: id, target, ...(article ? { article } : {}) });
res.json(result);
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// GET /api/auth/openai/status — is the user connected via OAuth?
app.get('/api/auth/openai/status', (_req, res) => {
+49 -30
View File
@@ -127,6 +127,7 @@ export interface TaskRecord {
interface TaskIndex {
ids: string[];
userMap: Record<string, string>; // id → ownerUsername
updatedAt: number;
}
@@ -148,8 +149,16 @@ function getTasksDir(): string {
return base;
}
function taskFilePath(id: string): string {
return path.join(getTasksDir(), `${id}.json`);
function getUserTasksDir(username: string): string {
const base = path.join(getStateBaseDir(), 'users', username, TASKS_DIR_NAME);
fs.mkdirSync(base, { recursive: true });
return base;
}
function taskFilePath(id: string, username?: string): string {
return username
? path.join(getUserTasksDir(username), `${id}.json`)
: path.join(getTasksDir(), `${id}.json`);
}
function indexFilePath(): string {
@@ -158,28 +167,31 @@ function indexFilePath(): string {
function loadIndex(): TaskIndex {
const p = indexFilePath();
if (!fs.existsSync(p)) return { ids: [], updatedAt: Date.now() };
if (!fs.existsSync(p)) return { ids: [], userMap: {}, updatedAt: Date.now() };
try {
const parsed = JSON.parse(fs.readFileSync(p, 'utf-8')) as unknown;
// Backward compatibility: older builds persisted the index as string[].
if (Array.isArray(parsed)) {
const ids = parsed.filter((v): v is string => typeof v === 'string');
return { ids: Array.from(new Set(ids)), updatedAt: Date.now() };
return { ids: Array.from(new Set(ids)), userMap: {}, updatedAt: Date.now() };
}
if (parsed && typeof parsed === 'object') {
const record = parsed as { ids?: unknown; updatedAt?: unknown };
const record = parsed as { ids?: unknown; userMap?: unknown; updatedAt?: unknown };
const ids = Array.isArray(record.ids)
? record.ids.filter((v): v is string => typeof v === 'string')
: [];
const userMap = (record.userMap && typeof record.userMap === 'object' && !Array.isArray(record.userMap))
? record.userMap as Record<string, string>
: {};
const updatedAt = typeof record.updatedAt === 'number' ? record.updatedAt : Date.now();
return { ids: Array.from(new Set(ids)), updatedAt };
return { ids: Array.from(new Set(ids)), userMap, updatedAt };
}
return { ids: [], updatedAt: Date.now() };
return { ids: [], userMap: {}, updatedAt: Date.now() };
} catch {
return { ids: [], updatedAt: Date.now() };
return { ids: [], userMap: {}, updatedAt: Date.now() };
}
}
@@ -187,18 +199,18 @@ function saveIndex(index: TaskIndex): void {
fs.writeFileSync(indexFilePath(), JSON.stringify(index, null, 2), 'utf-8');
}
function addToIndex(id: string): void {
function addToIndex(id: string, username?: string): void {
const idx = loadIndex();
if (!idx.ids.includes(id)) {
idx.ids.push(id);
idx.updatedAt = Date.now();
saveIndex(idx);
}
if (!idx.ids.includes(id)) idx.ids.push(id);
if (username) idx.userMap[id] = username;
idx.updatedAt = Date.now();
saveIndex(idx);
}
function removeFromIndex(id: string): void {
const idx = loadIndex();
idx.ids = idx.ids.filter(i => i !== id);
delete idx.userMap[id];
idx.updatedAt = Date.now();
saveIndex(idx);
}
@@ -259,26 +271,31 @@ export function createTask(params: {
};
saveTask(task);
addToIndex(id);
addToIndex(id, params.ownerUsername);
return task;
}
export function loadTask(id: string): TaskRecord | null {
const p = taskFilePath(id);
if (!fs.existsSync(p)) return null;
const idx = loadIndex();
const username = idx.userMap[id];
if (username) {
const userPath = taskFilePath(id, username);
if (fs.existsSync(userPath)) {
try { return JSON.parse(fs.readFileSync(userPath, 'utf-8')) as TaskRecord; } catch { /* fall through */ }
}
}
const globalPath = taskFilePath(id);
if (!fs.existsSync(globalPath)) return null;
try {
return JSON.parse(fs.readFileSync(p, 'utf-8')) as TaskRecord;
return JSON.parse(fs.readFileSync(globalPath, 'utf-8')) as TaskRecord;
} catch {
return null;
}
}
export function saveTask(task: TaskRecord): void {
// Trim journal to last 500 entries to prevent unbounded growth
if (task.journal.length > 500) {
task.journal = task.journal.slice(-500);
}
fs.writeFileSync(taskFilePath(task.id), JSON.stringify(task, null, 2), 'utf-8');
if (task.journal.length > 500) task.journal = task.journal.slice(-500);
fs.writeFileSync(taskFilePath(task.id, task.ownerUsername), JSON.stringify(task, null, 2), 'utf-8');
}
export function updateTaskStatus(
@@ -444,17 +461,18 @@ export function listTasks(filter?: { status?: TaskStatus[] }): TaskRecord[] {
}
export function deleteTask(id: string): boolean {
const p = taskFilePath(id);
const idx = loadIndex();
const inIndex = idx.ids.includes(id);
const username = idx.userMap[id];
let removedAny = false;
if (fs.existsSync(p)) {
try {
fs.unlinkSync(p);
removedAny = true;
} catch {
// best effort; continue cleanup of related artifacts
// Remove task file from user dir or global dir
const pathsToTry = username
? [taskFilePath(id, username), taskFilePath(id)]
: [taskFilePath(id)];
for (const p of pathsToTry) {
if (fs.existsSync(p)) {
try { fs.unlinkSync(p); removedAny = true; } catch { /* best effort */ }
}
}
@@ -468,6 +486,7 @@ export function deleteTask(id: string): boolean {
const base = getStateBaseDir();
const relatedFiles = [
path.join(base, 'sessions', `task_${id}.json`),
...(username ? [path.join(base, 'users', username, 'sessions', `task_${id}.json`)] : []),
path.join(base, 'jobs', 'file-op-v2', `task_${id}.json`),
// Backward-compat for older/alternate checkpoint naming.
path.join(base, 'jobs', 'file-op-v2', `${id}.json`),
+10 -1
View File
@@ -59,6 +59,8 @@ interface TelegramDeps {
resolveUsername?: (telegramUserId: string) => string | undefined;
/** If set, all messages on this channel are routed to this fixed username (per-user bot mode). */
fixedUsername?: string;
/** Fallback username when no user mapping is found — avoids legacy/global session storage. */
defaultUsername?: string | (() => string | undefined);
/** Called when a new chat ID is seen for the fixed user — used to persist it. */
onFirstChatId?: (username: string, chatId: number) => void;
}
@@ -119,7 +121,14 @@ export class TelegramChannel {
if (legacy) return legacy;
// 2. Per-user vault registration (self-service)
if (this.deps.resolveUsername) {
return this.deps.resolveUsername(String(userId));
const resolved = this.deps.resolveUsername(String(userId));
if (resolved) return resolved;
}
// 3. Fallback: avoid writing to legacy/global sessions
if (this.deps.defaultUsername) {
return typeof this.deps.defaultUsername === 'function'
? this.deps.defaultUsername()
: this.deps.defaultUsername;
}
return undefined;
}