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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user